Initialer Stand des PDF Tools

ZUGFeRD-E-Rechnungs-Konverter mit Vaadin-UI: E-Rechnung aus PDF oder
Rechnungstemplate erzeugen, Template-Designer, Adressbuch und
speicherbare Formulareingaben (MongoDB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:02:54 +02:00
commit 960431914c
42 changed files with 5829 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Vorlage fuer die .env-Datei: kopieren nach .env und Werte eintragen.
# cp .env.example .env
# MongoDB-Verbindung fuer die Rechnungstemplates
MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admin

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Zugangsdaten
.env
# Build
target/
# Vaadin
src/main/frontend/generated/
node_modules/
vite.config.ts.timestamp-*
# IDE / OS
.DS_Store

32
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,32 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "PDF Tool (Dev, Formular vorbelegt)",
"request": "launch",
"mainClass": "de.assecutor.pdftool.PdfToolApplication",
"projectName": "pdf-tool",
"cwd": "${workspaceFolder}",
"console": "internalConsole",
"vmArgs": "-Dspring.profiles.active=dev"
},
{
"type": "java",
"name": "PDF Tool (Port 8081)",
"request": "launch",
"mainClass": "de.assecutor.pdftool.PdfToolApplication",
"projectName": "pdf-tool",
"cwd": "${workspaceFolder}",
"console": "internalConsole",
"vmArgs": "-Dserver.port=8081"
},
{
"type": "java",
"name": "Attach an laufende JVM (5005)",
"request": "attach",
"hostName": "localhost",
"port": 5005
}
]
}

137
README.md Normal file
View File

@@ -0,0 +1,137 @@
# PDF Tool — ZUGFeRD E-Rechnungs-Konverter
Spring Boot + Vaadin Flow Anwendung, die ein herkömmliches B2B-Rechnungs-PDF in eine
**rechtskonforme ZUGFeRD-E-Rechnung** umwandelt (PDF/A-3 mit eingebettetem EN16931-XML,
Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als
**ZIP-Datei** bereitstellt.
Verwendet wird [Mustangproject](https://www.mustangproject.org/) (Apache-2.0), die
Open-Source-Referenzimplementierung von ZUGFeRD, inklusive des Mustang-Validators
(XSD + EN16931-Schematron für das XML, veraPDF für die PDF/A-3-Konformität).
> **Hinweis zur Rechtskonformität:** ZUGFeRD "signiert" nicht kryptografisch. Rechtskonform
> im Sinne der deutschen B2B-E-Rechnungspflicht (§ 14 UStG, seit 01.01.2025) ist eine Rechnung
> im strukturierten EN16931-Format — genau das erzeugt dieses Tool. Eine qualifizierte
> elektronische Signatur ist seit 2011 nicht mehr erforderlich.
## Voraussetzungen
- Java 21+
- Maven 3.9+
## Starten
```bash
mvn spring-boot:run
```
Die Weboberfläche ist dann unter <http://localhost:8080> erreichbar:
PDF hochladen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken, ZIP herunterladen.
Produktions-Build:
```bash
mvn clean package -Pproduction
java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar
```
## REST-API
### E-Rechnung erzeugen
`POST /api/invoices/sign` (multipart/form-data) mit zwei Parts:
| Part | Inhalt |
|------------|-------------------------------------------|
| `file` | das Rechnungs-PDF |
| `metadata` | Rechnungsdaten als JSON (siehe unten) |
Antwort: `application/zip` mit `<Rechnungsnummer>-zugferd.pdf`, `factur-x.xml` und
`validation-report.xml` (Mustang-Prüfbericht). Der Header `X-Zugferd-Valid: true|false`
meldet das Validierungsergebnis. Bei Fehlern: HTTP 400 mit `{"error": "..."}`.
> Häufigste Ursache für `X-Zugferd-Valid: false`: Das Quell-PDF bettet seine
> Schriften nicht ein. Das kann die Konvertierung nicht reparieren — das PDF muss
> dann mit eingebetteten Schriften neu erzeugt werden (Standard bei den meisten
> ERP-/Reporting-Systemen konfigurierbar).
### Bestehende E-Rechnung prüfen
`POST /api/invoices/validate` (multipart/form-data, Part `file` = ZUGFeRD-PDF oder
Factur-X-XML). Antwort: der Mustang-Prüfbericht als XML — HTTP 200 bei gültiger,
HTTP 422 bei ungültiger Rechnung, ebenfalls mit `X-Zugferd-Valid`-Header.
```bash
curl -X POST http://localhost:8080/api/invoices/validate \
-F "file=@rechnung-zugferd.pdf"
```
### Beispiel
```bash
curl -X POST http://localhost:8080/api/invoices/sign \
-F "file=@rechnung.pdf" \
-F "metadata=@metadata.json;type=application/json" \
-o rechnung-zugferd.zip
```
`metadata.json`:
```json
{
"invoiceNumber": "RE-2026-0815",
"issueDate": "2026-07-08",
"deliveryDate": "2026-07-01",
"dueDate": "2026-07-22",
"currency": "EUR",
"paymentTerms": "Zahlbar innerhalb von 14 Tagen ohne Abzug.",
"sender": {
"name": "Assecutor GmbH",
"street": "Musterstrasse 1",
"zip": "20095",
"city": "Hamburg",
"countryCode": "DE",
"vatId": "DE123456789"
},
"recipient": {
"name": "Kunde AG",
"street": "Beispielweg 2",
"zip": "10115",
"city": "Berlin",
"countryCode": "DE"
},
"items": [
{
"description": "Beratungsleistung",
"quantity": 1,
"unitPriceNet": 1500.00,
"vatPercent": 19
}
]
}
```
Pflichtfelder: `invoiceNumber`, `issueDate`, `sender` (inkl. Adresse), `recipient`,
mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum zurück,
`dueDate` auf Rechnungsdatum + 14 Tage, `currency` auf `EUR`.
## 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
- [InvoiceSigningController](src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java) — REST-Endpunkte `/sign` und `/validate`
- [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
## Tests
```bash
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.

127
pom.xml Normal file
View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/>
</parent>
<groupId>de.assecutor</groupId>
<artifactId>pdf-tool</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>pdf-tool</name>
<description>ZUGFeRD E-Rechnungs-Konverter (PDF zu PDF/A-3 mit eingebettetem EN16931-XML)</description>
<properties>
<java.version>21</java.version>
<vaadin.version>24.7.4</vaadin.version>
<mustang.version>2.24.0</mustang.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>${vaadin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MongoDB: Ablage der Rechnungstemplates (Collection invoice_templates) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<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>
<groupId>com.itextpdf</groupId>
<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>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>production</id>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>build-frontend</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -0,0 +1,32 @@
This directory is automatically generated by Vaadin and contains the pre-compiled
frontend files/resources for your project (frontend development bundle).
It should be added to Version Control System and committed, so that other developers
do not have to compile it again.
Frontend development bundle is automatically updated when needed:
- an npm/pnpm package is added with @NpmPackage or directly into package.json
- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
- Vaadin add-on with front-end customizations is added
- Custom theme imports/assets added into 'theme.json' file
- Exported web component is added.
If your project development needs a hot deployment of the frontend changes,
you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
- set `vaadin.frontend.hotdeploy=true` in `application.properties`
- configure `vaadin-maven-plugin`:
```
<configuration>
<frontendHotdeploy>true</frontendHotdeploy>
</configuration>
```
- configure `jetty-maven-plugin`:
```
<configuration>
<systemProperties>
<vaadin.frontend.hotdeploy>true</vaadin.frontend.hotdeploy>
</systemProperties>
</configuration>
```
Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle).

BIN
src/main/bundles/dev.bundle Normal file

Binary file not shown.

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
html, body, #outlet {
height: 100%;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
/* Styles für den Rechnungsgenerator (Canvas-Template-Editor).
Angelehnt an das votianlt-Theme, reduziert auf Lumo-Variablen. */
.invoice-generator-view {
gap: var(--lumo-space-m);
}
.invoice-generator-main {
gap: var(--lumo-space-m);
}
.invoice-generator-panel {
border: 1px solid var(--lumo-contrast-20pct);
border-radius: var(--lumo-border-radius-l);
background: var(--lumo-base-color);
box-shadow: var(--lumo-box-shadow-xs);
box-sizing: border-box;
}
.invoice-generator-panel-title {
color: var(--lumo-header-text-color);
font-size: var(--lumo-font-size-m);
font-weight: 700;
}
.invoice-generator-info {
color: var(--lumo-secondary-text-color);
font-size: var(--lumo-font-size-s);
}
.invoice-generator-template {
display: flex;
align-items: center;
gap: var(--lumo-space-s);
margin: var(--lumo-space-xs) 0;
padding: var(--lumo-space-s) var(--lumo-space-m);
border: 1px solid var(--lumo-contrast-20pct);
border-radius: var(--lumo-border-radius-m);
background: var(--lumo-contrast-5pct);
cursor: grab;
user-select: none;
font-size: var(--lumo-font-size-s);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.invoice-generator-template:hover {
box-shadow: var(--lumo-box-shadow-xs);
border-color: var(--lumo-primary-color-50pct);
}
.invoice-generator-canvas {
position: relative;
overflow: hidden;
cursor: default;
border: 2px dashed var(--lumo-contrast-30pct);
border-radius: var(--lumo-border-radius-l);
background: var(--lumo-base-color);
}
.invoice-generator-actionbar {
width: 100%;
padding: var(--lumo-space-s) var(--lumo-space-m);
border: 1px solid var(--lumo-contrast-20pct);
border-radius: var(--lumo-border-radius-l);
background: var(--lumo-base-color);
box-sizing: border-box;
}
.invoice-generator-pdf {
display: flex;
flex-direction: column;
overflow: hidden;
}
.invoice-generator-frame {
border: none;
}

View File

@@ -0,0 +1,12 @@
package de.assecutor.pdftool;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PdfToolApplication {
public static void main(String[] args) {
SpringApplication.run(PdfToolApplication.class, args);
}
}

View File

@@ -0,0 +1,124 @@
package de.assecutor.pdftool.address;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Eine Firmenadresse im Adressbuch (MongoDB-Collection {@code addresses}).
* Eine Adresse ist entweder Rechnungssteller oder Rechnungsempfänger und
* enthält die Felder, die auch im E-Rechnungs-Formular erfasst werden.
*/
@Document(collection = "addresses")
public class Address {
@Id
private String id;
private AddressType type;
private String name;
private String street;
private String zip;
private String city;
private String countryCode;
private String vatId;
private String email;
private String phone;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Address() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void touch() {
this.updatedAt = LocalDateTime.now();
}
public String getId() {
return id;
}
public AddressType getType() {
return type;
}
public void setType(AddressType type) {
this.type = type;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getStreet() {
return street;
}
public void setStreet(String street) {
this.street = street;
}
public String getZip() {
return zip;
}
public void setZip(String zip) {
this.zip = zip;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getCountryCode() {
return countryCode;
}
public void setCountryCode(String countryCode) {
this.countryCode = countryCode;
}
public String getVatId() {
return vatId;
}
public void setVatId(String vatId) {
this.vatId = vatId;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,12 @@
package de.assecutor.pdftool.address;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AddressRepository extends MongoRepository<Address, String> {
List<Address> findByType(AddressType type);
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.pdftool.address;
/** Verwendungszweck einer Adresse im Adressbuch. */
public enum AddressType {
RECHNUNGSSTELLER("Rechnungssteller"),
RECHNUNGSEMPFAENGER("Rechnungsempfänger");
private final String label;
AddressType(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}

View File

@@ -0,0 +1,116 @@
package de.assecutor.pdftool.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import de.assecutor.pdftool.zugferd.ZugferdConversionException;
import de.assecutor.pdftool.zugferd.ZugferdResult;
import de.assecutor.pdftool.zugferd.ZugferdService;
import de.assecutor.pdftool.zugferd.ZugferdValidationService;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen.
*
* <pre>
* curl -X POST http://localhost:8080/api/invoices/sign \
* -F "file=@rechnung.pdf" \
* -F "metadata=@metadata.json;type=application/json" \
* -o rechnung-zugferd.zip
*
* curl -X POST http://localhost:8080/api/invoices/validate \
* -F "file=@rechnung-zugferd.pdf"
* </pre>
*/
@RestController
@RequestMapping("/api/invoices")
public class InvoiceSigningController {
/** Response-Header mit dem Ergebnis der Mustang-Validierung (true/false). */
public static final String VALIDATION_HEADER = "X-Zugferd-Valid";
private final ZugferdService zugferdService;
private final ZugferdValidationService validationService;
private final ObjectMapper objectMapper;
private final Validator validator;
public InvoiceSigningController(ZugferdService zugferdService,
ZugferdValidationService validationService,
ObjectMapper objectMapper,
Validator validator) {
this.zugferdService = zugferdService;
this.validationService = validationService;
this.objectMapper = objectMapper;
this.validator = validator;
}
@PostMapping(value = "/sign", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> sign(@RequestPart("file") MultipartFile file,
@RequestPart("metadata") String metadataJson) throws IOException {
InvoiceMetadata metadata = parseAndValidate(metadataJson);
ZugferdResult result = zugferdService.createZugferdZip(file.getBytes(), metadata);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("application/zip"));
headers.setContentDisposition(ContentDisposition.attachment().filename(result.zipFileName()).build());
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
return new ResponseEntity<>(result.zip(), headers, HttpStatus.OK);
}
/**
* Validiert eine bestehende ZUGFeRD-Rechnung (PDF oder Factur-X-XML).
* Antwort ist der Mustang-Prüfbericht als XML; HTTP 200 bei gültiger,
* HTTP 422 bei ungültiger Rechnung.
*/
@PostMapping(value = "/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> validate(@RequestPart("file") MultipartFile file) throws IOException {
ZugferdValidationService.ValidationResult result =
validationService.validate(file.getBytes(), file.getOriginalFilename());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
return new ResponseEntity<>(result.reportXml(), headers,
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
}
private InvoiceMetadata parseAndValidate(String metadataJson) {
InvoiceMetadata metadata;
try {
metadata = objectMapper.readValue(metadataJson, InvoiceMetadata.class);
} catch (IOException e) {
throw new ZugferdConversionException("Metadaten sind kein gültiges JSON: " + e.getMessage(), e);
}
Set<ConstraintViolation<InvoiceMetadata>> violations = validator.validate(metadata);
if (!violations.isEmpty()) {
String details = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.collect(Collectors.joining("; "));
throw new ZugferdConversionException("Metadaten unvollständig: " + details);
}
return metadata;
}
@ExceptionHandler(ZugferdConversionException.class)
public ResponseEntity<Map<String, String>> handleConversionError(ZugferdConversionException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}

View File

@@ -0,0 +1,26 @@
package de.assecutor.pdftool.invoice;
import com.vaadin.flow.spring.annotation.VaadinSessionScope;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Hält die zuletzt in der E-Rechnungs-Maske erfassten Rechnungspositionen für
* die Dauer der Sitzung, damit der Rechnungsgenerator sie in der
* Positionsliste und der PDF-Vorschau verwenden kann.
*/
@Component
@VaadinSessionScope
public class CapturedInvoiceData {
private List<InvoiceMetadata.LineItem> items = List.of();
public List<InvoiceMetadata.LineItem> getItems() {
return items;
}
public void setItems(List<InvoiceMetadata.LineItem> items) {
this.items = items != null ? List.copyOf(items) : List.of();
}
}

View File

@@ -0,0 +1,35 @@
package de.assecutor.pdftool.invoice;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* Unter einem Namen gespeicherte Eingaben der E-Rechnungs-Maske
* (MongoDB-Collection {@code invoice_drafts}). Ein hochgeladenes PDF wird
* nicht mitgespeichert, wohl aber der Name des gewählten Templates.
*/
@Document(collection = "invoice_drafts")
public record InvoiceDraft(
@Id String id,
@Indexed(unique = true) String name,
String templateName,
String invoiceNumber,
LocalDate issueDate,
LocalDate deliveryDate,
LocalDate dueDate,
String currency,
String paymentTerms,
String buyerReference,
String iban,
String bic,
InvoiceMetadata.Party sender,
InvoiceMetadata.Party recipient,
List<InvoiceMetadata.LineItem> items,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.pdftool.invoice;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface InvoiceDraftRepository extends MongoRepository<InvoiceDraft, String> {
Optional<InvoiceDraft> findByName(String name);
boolean existsByName(String name);
}

View File

@@ -0,0 +1,46 @@
package de.assecutor.pdftool.invoice;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet gespeicherte Eingaben der E-Rechnungs-Maske in MongoDB
* (Collection {@code invoice_drafts}); jede Speicherung liegt unter einem
* eindeutigen Namen.
*/
@Service
public class InvoiceDraftService {
private final InvoiceDraftRepository repository;
public InvoiceDraftService(InvoiceDraftRepository repository) {
this.repository = repository;
}
/** Speichert die Eingaben unter ihrem Namen (legt sie an oder überschreibt die bestehenden). */
public void save(InvoiceDraft draft) {
repository.findByName(draft.name())
.ifPresent(existing -> repository.deleteById(existing.id()));
repository.save(draft);
}
/** @return die gespeicherten Eingaben mit dem Namen oder leer, wenn keine existieren. */
public Optional<InvoiceDraft> load(String name) {
return repository.findByName(name);
}
/** @return true, wenn unter dem Namen bereits Eingaben gespeichert sind. */
public boolean exists(String name) {
return repository.existsByName(name);
}
/** @return die Namen aller gespeicherten Eingaben, alphabetisch sortiert. */
public List<String> names() {
return repository.findAll().stream()
.map(draft -> draft.name())
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
}

View File

@@ -0,0 +1,56 @@
package de.assecutor.pdftool.invoice;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* Rechnungsdaten, die als EN16931-XML (Profil EN 16931 / "Comfort") in das
* PDF/A-3 eingebettet werden. Ohne diese strukturierten Daten ist eine
* ZUGFeRD-Rechnung nicht rechtskonform.
*/
public record InvoiceMetadata(
@NotBlank String invoiceNumber,
@NotNull LocalDate issueDate,
LocalDate deliveryDate,
LocalDate dueDate,
String currency,
String paymentTerms,
// Käuferreferenz bzw. Leitweg-ID (BT-10) — für Rechnungen an Behörden erforderlich
String buyerReference,
// Zahlungsverbindung des Rechnungsstellers (BG-16, SEPA-Überweisung)
String iban,
String bic,
@NotNull @Valid Party sender,
@NotNull @Valid Party recipient,
@NotEmpty @Valid List<LineItem> items
) {
public record Party(
@NotBlank String name,
@NotBlank String street,
@NotBlank String zip,
@NotBlank String city,
@NotBlank String countryCode,
@ValidVatId String vatId,
// Elektronische Adresse (BT-34/BT-49) — von PEPPOL-EN16931 gefordert
@NotBlank @Email String email,
// Telefon des Verkäufer-Kontakts (BR-DE-6); für den Empfänger optional
String phone
) {
}
public record LineItem(
@NotBlank String description,
@NotNull BigDecimal quantity,
@NotNull BigDecimal unitPriceNet,
@NotNull BigDecimal vatPercent
) {
}
}

View File

@@ -0,0 +1,45 @@
package de.assecutor.pdftool.invoice;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Optional;
/**
* Bean-Validation-Constraint für USt-IdNrn. Leere Werte gelten als gültig —
* ob das Feld Pflicht ist, regeln {@code @NotBlank} bzw. die UI.
*/
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidVatId.Validator.class)
public @interface ValidVatId {
String message() default "ungültige USt-IdNr.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements ConstraintValidator<ValidVatId, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true;
}
Optional<String> error = VatIdValidator.validate(value);
if (error.isPresent()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(error.get()).addConstraintViolation();
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,106 @@
package de.assecutor.pdftool.invoice;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* Prüft Umsatzsteuer-Identifikationsnummern (USt-IdNr.).
*
* Geprüft werden das länderspezifische Format aller EU-Mitgliedsstaaten
* (plus XI für Nordirland) sowie bei deutschen IdNrn. zusätzlich die
* Prüfziffer nach ISO 7064, MOD 11,10 (§ 27a UStG).
*/
public final class VatIdValidator {
/** Format des nationalen Teils (ohne Länderpräfix) je EU-Land. */
private static final Map<String, Pattern> EU_FORMATS = Map.ofEntries(
Map.entry("AT", Pattern.compile("U\\d{8}")),
Map.entry("BE", Pattern.compile("[01]\\d{9}")),
Map.entry("BG", Pattern.compile("\\d{9,10}")),
Map.entry("CY", Pattern.compile("\\d{8}[A-Z]")),
Map.entry("CZ", Pattern.compile("\\d{8,10}")),
Map.entry("DE", Pattern.compile("[1-9]\\d{8}")),
Map.entry("DK", Pattern.compile("\\d{8}")),
Map.entry("EE", Pattern.compile("\\d{9}")),
Map.entry("EL", Pattern.compile("\\d{9}")),
Map.entry("ES", Pattern.compile("[A-Z0-9]\\d{7}[A-Z0-9]")),
Map.entry("FI", Pattern.compile("\\d{8}")),
Map.entry("FR", Pattern.compile("[A-Z0-9]{2}\\d{9}")),
Map.entry("HR", Pattern.compile("\\d{11}")),
Map.entry("HU", Pattern.compile("\\d{8}")),
Map.entry("IE", Pattern.compile("\\d{7}[A-W][A-I]?|\\d[A-Z+*]\\d{5}[A-W]")),
Map.entry("IT", Pattern.compile("\\d{11}")),
Map.entry("LT", Pattern.compile("\\d{9}|\\d{12}")),
Map.entry("LU", Pattern.compile("\\d{8}")),
Map.entry("LV", Pattern.compile("\\d{11}")),
Map.entry("MT", Pattern.compile("\\d{8}")),
Map.entry("NL", Pattern.compile("\\d{9}B\\d{2}")),
Map.entry("PL", Pattern.compile("\\d{10}")),
Map.entry("PT", Pattern.compile("\\d{9}")),
Map.entry("RO", Pattern.compile("\\d{2,10}")),
Map.entry("SE", Pattern.compile("\\d{10}01")),
Map.entry("SI", Pattern.compile("\\d{8}")),
Map.entry("SK", Pattern.compile("\\d{10}")),
Map.entry("XI", Pattern.compile("\\d{9}(\\d{3})?"))
);
private VatIdValidator() {
}
/**
* @return leeres Optional, wenn die USt-IdNr. gültig ist,
* sonst eine deutschsprachige Fehlermeldung.
*/
public static Optional<String> validate(String vatId) {
if (vatId == null || vatId.isBlank()) {
return Optional.of("USt-IdNr. fehlt.");
}
String normalized = normalize(vatId);
if (normalized.length() < 4
|| !Character.isLetter(normalized.charAt(0))
|| !Character.isLetter(normalized.charAt(1))) {
return Optional.of("USt-IdNr. muss mit einem Länderpräfix beginnen, z. B. DE.");
}
String country = normalized.substring(0, 2);
String body = normalized.substring(2);
Pattern format = EU_FORMATS.get(country);
if (format == null) {
return Optional.of("Unbekanntes Länderpräfix \"" + country + "\".");
}
if (!format.matcher(body).matches()) {
return Optional.of("USt-IdNr. entspricht nicht dem Format für " + country + ".");
}
if ("DE".equals(country) && !checkDigitValidDe(body)) {
return Optional.of("Prüfziffer der deutschen USt-IdNr. ist ungültig.");
}
return Optional.empty();
}
public static boolean isValid(String vatId) {
return validate(vatId).isEmpty();
}
/** Entfernt Leerzeichen, Punkte und Bindestriche und wandelt in Großbuchstaben um. */
public static String normalize(String vatId) {
return vatId.replaceAll("[\\s.\\-]", "").toUpperCase();
}
/** Prüfziffernverfahren ISO 7064, MOD 11,10 für deutsche USt-IdNrn. */
private static boolean checkDigitValidDe(String digits) {
int product = 10;
for (int i = 0; i < 8; i++) {
int sum = (Character.getNumericValue(digits.charAt(i)) + product) % 10;
if (sum == 0) {
sum = 10;
}
product = (2 * sum) % 11;
}
int checkDigit = 11 - product;
if (checkDigit == 10) {
checkDigit = 0;
}
return checkDigit == Character.getNumericValue(digits.charAt(8));
}
}

View File

@@ -0,0 +1,65 @@
package de.assecutor.pdftool.template;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Ein Rechnungstemplate des Rechnungsgenerators in MongoDB. Das eigentliche
* Template ist die JSON-Serialisierung der Canvas-Elemente im Feld
* {@link #templateData}.
*/
@Document(collection = "invoice_templates")
public class InvoiceTemplate {
@Id
private String id;
/** Eindeutiger Name des Templates, z. B. "standard". */
@Indexed(unique = true)
private String name;
/** JSON der Canvas-Elemente ({"elements": [...]}). */
private String templateData;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public InvoiceTemplate() {
}
public InvoiceTemplate(String name, String templateData) {
this.name = name;
this.templateData = templateData;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void updateTemplate(String templateData) {
this.templateData = templateData;
this.updatedAt = LocalDateTime.now();
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public String getTemplateData() {
return templateData;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.pdftool.template;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface InvoiceTemplateRepository extends MongoRepository<InvoiceTemplate, String> {
Optional<InvoiceTemplate> findByName(String name);
boolean existsByName(String name);
}

View File

@@ -0,0 +1,100 @@
package de.assecutor.pdftool.template;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet die Rechnungstemplates des Rechnungsgenerators in MongoDB
* (Collection {@code invoice_templates}). Templates werden unter einem
* eindeutigen Namen gespeichert; es können beliebig viele Templates
* nebeneinander existieren. Die Verbindung wird über {@code MONGODB_URI}
* in der .env-Datei konfiguriert.
*
* <p>Ein früher als Datei abgelegtes Template (~/.pdf-tool/rechnungstemplate.json)
* wird beim ersten Zugriff einmalig als "{@value #DEFAULT_TEMPLATE_NAME}" in die
* Datenbank übernommen.</p>
*/
@Service
public class InvoiceTemplateService {
private static final Logger log = LoggerFactory.getLogger(InvoiceTemplateService.class);
/** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */
public static final String DEFAULT_TEMPLATE_NAME = "standard";
private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json";
private final InvoiceTemplateRepository repository;
private final Path legacyTemplateFile;
public InvoiceTemplateService(InvoiceTemplateRepository repository,
@Value("${pdftool.template-dir:${user.home}/.pdf-tool}") String legacyTemplateDir) {
this.repository = repository;
this.legacyTemplateFile = Path.of(legacyTemplateDir, LEGACY_TEMPLATE_FILE_NAME);
}
/** Speichert das Template unter dem Namen (legt es an oder überschreibt das bestehende). */
public void saveTemplate(String name, String templateData) {
Optional<InvoiceTemplate> existing = repository.findByName(name);
if (existing.isPresent()) {
InvoiceTemplate template = existing.get();
template.updateTemplate(templateData);
repository.save(template);
} else {
repository.save(new InvoiceTemplate(name, templateData));
}
}
/** @return true, wenn unter dem Namen bereits ein Template gespeichert ist. */
public boolean templateExists(String name) {
return repository.existsByName(name);
}
/** @return die Namen aller gespeicherten Templates, alphabetisch sortiert. */
public List<String> templateNames() {
migrateLegacyTemplateIfMissing();
return repository.findAll().stream()
.map(template -> template.getName())
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
/** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */
public Optional<String> loadTemplate(String name) {
if (DEFAULT_TEMPLATE_NAME.equals(name)) {
migrateLegacyTemplateIfMissing();
}
return repository.findByName(name)
.map(template -> template.getTemplateData())
.filter(data -> !data.isBlank());
}
/**
* Übernimmt das frühere Datei-Template einmalig als Standard-Template in die
* Datenbank; die Datei bleibt als Sicherung unverändert liegen.
*/
private void migrateLegacyTemplateIfMissing() {
if (repository.existsByName(DEFAULT_TEMPLATE_NAME) || !Files.exists(legacyTemplateFile)) {
return;
}
try {
String data = Files.readString(legacyTemplateFile, StandardCharsets.UTF_8);
if (data.isBlank()) {
return;
}
saveTemplate(DEFAULT_TEMPLATE_NAME, data);
log.info("Rechnungstemplate aus {} in die MongoDB übernommen.", legacyTemplateFile);
} catch (IOException e) {
log.warn("Altes Datei-Template {} konnte nicht gelesen werden: {}", legacyTemplateFile, e.getMessage());
}
}
}

View File

@@ -0,0 +1,49 @@
package de.assecutor.pdftool.template;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.mongo.MongoClientSettingsBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Prüft beim Start die Verbindung zum MongoDB-Server und bricht den Start ab,
* wenn er nicht erreichbar ist — die Anwendung setzt die Datenbank zwingend
* voraus. Konfiguriert wird die Verbindung über MONGODB_URI in der .env-Datei.
*/
@Component
public class MongoConnectionCheck implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(MongoConnectionCheck.class);
private final MongoTemplate mongoTemplate;
public MongoConnectionCheck(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public void run(ApplicationArguments args) {
try {
mongoTemplate.executeCommand("{ ping: 1 }");
log.info("MongoDB-Verbindung geprüft, Datenbank '{}' ist erreichbar.",
mongoTemplate.getDb().getName());
} catch (Exception e) {
throw new IllegalStateException(
"Keine Verbindung zum MongoDB-Server. Die Anwendung benötigt die Datenbank zwingend — "
+ "bitte MONGODB_URI in der .env-Datei prüfen. Ursache: " + e.getMessage(), e);
}
}
/** Kurzer Auswahl-Timeout, damit ein nicht erreichbarer Server den Start schnell abbricht. */
@Bean
static MongoClientSettingsBuilderCustomizer serverSelectionTimeoutCustomizer() {
return builder -> builder.applyToClusterSettings(
cluster -> cluster.serverSelectionTimeout(5, TimeUnit.SECONDS));
}
}

View File

@@ -0,0 +1,336 @@
package de.assecutor.pdftool.template;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itextpdf.html2pdf.HtmlConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Rendert ein Canvas-Template des Rechnungsgenerators nach PDF. Das Template
* ist ein JSON der Form {@code {"elements": [...]}} mit prozentualen
* Koordinaten; daraus wird absolut positioniertes HTML (mm auf A4) erzeugt und
* mit iText html2pdf konvertiert. Portiert aus votianlt
* (CustomerInvoiceService.generatePdfFromCanvasTemplate).
*/
@Service
public class TemplatePdfService {
private static final Logger log = LoggerFactory.getLogger(TemplatePdfService.class);
/**
* Rendert das Template mit den übergebenen Variablenwerten (z. B.
* {@code masterdata.*}, {@code customer.*}, {@code services.json}).
*/
public byte[] generatePdf(String jsonTemplateData, Map<String, String> variables, BigDecimal vatRate) {
try {
return renderPdf(jsonTemplateData, variables, vatRate);
} catch (Exception e) {
throw new TemplateRenderException(
"Das Rechnungstemplate konnte nicht als PDF gerendert werden: " + e.getMessage(), e);
}
}
private byte[] renderPdf(String jsonTemplateData, Map<String, String> variables, BigDecimal vatRate)
throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate : new BigDecimal("0.19");
ObjectMapper mapper = new ObjectMapper();
JsonNode rootNode = mapper.readTree(jsonTemplateData);
JsonNode elements = rootNode.get("elements");
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<!DOCTYPE html>");
htmlBuilder.append("<html><head>");
htmlBuilder.append("<meta charset='UTF-8'>");
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
htmlBuilder.append(".image { overflow: hidden; }");
htmlBuilder.append(".image img { width: 100%; height: 100%; object-fit: contain; display: block; }");
htmlBuilder.append("</style>");
htmlBuilder.append("</head><body>");
if (elements != null && elements.isArray()) {
for (JsonNode element : elements) {
appendElement(htmlBuilder, element, variables, effectiveVatRate);
}
}
htmlBuilder.append("</body></html>");
ByteArrayOutputStream out = new ByteArrayOutputStream();
HtmlConverter.convertToPdf(htmlBuilder.toString(), out);
return out.toByteArray();
}
private void appendElement(StringBuilder htmlBuilder, JsonNode element, Map<String, String> variables,
BigDecimal effectiveVatRate) {
String type = element.has("type") ? element.get("type").asText("text") : "text";
String variable = element.has("variable") ? element.get("variable").asText(null) : null;
// Elemente mit Variable beziehen ihren Text zur Renderzeit aus der
// Variablen-Map; nur freie Elemente verwenden den gespeicherten Text.
String text;
if (variable != null && !variable.isEmpty()) {
text = "";
} else {
text = element.has("text") ? element.get("text").asText("") : "";
}
// Prozent-Koordinaten; Legacy-Fallback: Pixel auf Basis 595x842
double xPercent = element.has("xPercent") ? element.get("xPercent").asDouble(0)
: element.get("x").asDouble(0) / 595.0 * 100;
double yPercent = element.has("yPercent") ? element.get("yPercent").asDouble(0)
: element.get("y").asDouble(0) / 842.0 * 100;
double widthPercent = element.has("widthPercent") ? element.get("widthPercent").asDouble(15)
: element.get("width").asDouble(150) / 595.0 * 100;
double heightPercent = element.has("heightPercent") ? element.get("heightPercent").asDouble(3)
: element.get("height").asDouble(30) / 842.0 * 100;
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
// Prozent -> mm (A4: 210mm x 297mm)
double mmX = xPercent / 100.0 * 210.0;
double mmY = yPercent / 100.0 * 297.0;
double mmWidth = widthPercent / 100.0 * 210.0;
double mmHeight = heightPercent / 100.0 * 297.0;
htmlBuilder.append("<div class='element ").append(type).append("' ");
htmlBuilder.append("style='");
htmlBuilder.append("left:").append(String.format(Locale.US, "%.2f", mmX)).append("mm;");
htmlBuilder.append("top:").append(String.format(Locale.US, "%.2f", mmY)).append("mm;");
if ("line".equals(type)) {
htmlBuilder.append("width:").append(String.format(Locale.US, "%.2f", mmWidth)).append("mm;");
htmlBuilder.append("height:0;border-top:1px solid #333;");
} else if ("vline".equals(type)) {
htmlBuilder.append("width:0;border-left:1px solid #333;");
htmlBuilder.append("height:").append(String.format(Locale.US, "%.2f", mmHeight)).append("mm;");
} else {
htmlBuilder.append("width:").append(String.format(Locale.US, "%.2f", mmWidth)).append("mm;");
htmlBuilder.append("height:").append(String.format(Locale.US, "%.2f", mmHeight)).append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";");
// services.list als Block, damit die Tabelle die Breite füllen kann
if ("services.list".equals(variable)) {
htmlBuilder.append("display:block;overflow:visible;padding:0;");
} else {
htmlBuilder.append("display:flex;align-items:center;");
switch (textAlign) {
case "center":
htmlBuilder.append("justify-content:center;text-align:center;");
break;
case "right":
htmlBuilder.append("justify-content:flex-end;text-align:right;");
break;
default:
htmlBuilder.append("justify-content:flex-start;text-align:left;");
break;
}
}
}
htmlBuilder.append("'>");
// Variablen zuerst ersetzen, dann escapen
if (variable != null && variables.containsKey(variable)) {
text = variables.get(variable);
} else if (text != null && text.startsWith("{{")) {
// Legacy-Format {{variable}}|Anzeigetext bzw. {{variable}}
int pipeIndex = text.indexOf('|');
String placeholderPart = pipeIndex > -1 ? text.substring(0, pipeIndex) : text;
if (placeholderPart.startsWith("{{") && placeholderPart.endsWith("}}")) {
String varName = placeholderPart.substring(2, placeholderPart.length() - 2);
if (variables.containsKey(varName)) {
text = variables.get(varName);
}
}
}
if (text != null) {
text = escapeHtml(text).replace("\n", "<br>");
} else {
text = "";
}
if ("image".equals(type)) {
if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) {
String imageData = element.get("imageData").asText();
if (!imageData.startsWith("data:")) {
imageData = "data:image/png;base64," + imageData;
}
htmlBuilder.append(
"<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;'>");
htmlBuilder.append("<img src=\"").append(imageData.replace("\"", "%22"))
.append("\" style='max-width:100%;max-height:100%;object-fit:contain;' alt='Bild' />");
htmlBuilder.append("</div>");
} else {
htmlBuilder.append(
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
}
} else if ("services.list".equals(variable)) {
if (variables.containsKey("services.json")) {
htmlBuilder.append(generateServicesTableHtmlWithData(variables));
} else {
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate));
}
} else if (text.contains("<br>")) {
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
htmlBuilder.append("<span>").append(text).append("</span>");
} else {
htmlBuilder.append("<span style='white-space:nowrap;'>").append(text).append("</span>");
}
htmlBuilder.append("</div>");
}
/** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */
private String generateServicesTableHtml(BigDecimal vatRate) {
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros();
if (pct.scale() < 0) {
pct = pct.setScale(0);
}
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
String[][] sampleData = { { "Beratungsleistung", vatLabel, "450,00 €" },
{ "Softwareentwicklung", vatLabel, "1.200,00 €" }, { "Support-Pauschale", vatLabel, "150,00 €" } };
double netTotal = 1800.00;
double grossTotal = netTotal + (netTotal * vatRate.doubleValue());
StringBuilder html = new StringBuilder();
html.append("<div style='width:100%;box-sizing:border-box;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(tableHeaderRow());
for (int i = 0; i < sampleData.length; i++) {
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'>")
.append(sampleData[i][0]).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][1])
.append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][2])
.append("</td>");
html.append("</tr>");
}
html.append("</table>");
html.append("<div style='margin-top:8px;width:100%;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(summaryRow("Nettosumme:", String.format(Locale.GERMANY, "%,.2f €", netTotal), false));
html.append(summaryRow("Gesamtsumme:", String.format(Locale.GERMANY, "%,.2f €", grossTotal), true));
html.append("</table>");
html.append("</div>");
html.append("</div>");
return html.toString();
}
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
private String generateServicesTableHtmlWithData(Map<String, String> variables) {
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
List<Map<String, String>> servicesData = new ArrayList<>();
String servicesJson = variables.get("services.json");
if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) {
try {
servicesData = new ObjectMapper().readValue(servicesJson, new TypeReference<>() {
});
} catch (Exception e) {
log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage());
}
}
StringBuilder html = new StringBuilder();
html.append("<div style='width:100%;box-sizing:border-box;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(tableHeaderRow());
if (servicesData.isEmpty()) {
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
html.append(
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Positionen vorhanden</td>");
html.append("</tr>");
} else {
for (int i = 0; i < servicesData.size(); i++) {
Map<String, String> service = servicesData.get(i);
String name = service.getOrDefault("name", "Unbekannte Position");
String netAmount = service.getOrDefault("netAmount", "0,00");
// USt-Satz pro Position, Fallback: einheitlicher Satz der Rechnung
String rowVat = service.getOrDefault("vat", vatRateLabel);
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>")
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.append(escapeHtml(rowVat)).append("</td>");
// € nur an rein numerische Beträge anhängen; Werte wie "15 %" unverändert
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + "" : escapeHtml(netAmount);
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(amountDisplay).append("</td>");
html.append("</tr>");
}
}
html.append("</table>");
html.append("<div style='margin-top:8px;width:100%;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
// Bei gemischten USt-Sätzen (leeres Label) ohne Satzangabe beschriften
String vatSummaryLabel = vatRateLabel.isEmpty() ? "zzgl. USt:"
: "zzgl. " + escapeHtml(vatRateLabel) + " USt:";
html.append(summaryRow("Nettosumme:", netTotal, false));
html.append(summaryRow(vatSummaryLabel, vatTotal, false));
html.append(summaryRow("Gesamtsumme:", grossTotal, true));
html.append("</table>");
html.append("</div>");
html.append("</div>");
return html.toString();
}
private String tableHeaderRow() {
return "<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>"
+ "<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>"
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>"
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>"
+ "</tr>";
}
private String summaryRow(String label, String value, boolean emphasized) {
String labelStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" : "padding:2px 8px;";
String valueStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;"
: "padding:2px 8px;font-weight:bold;";
return "<tr>"
+ "<td style='width:55%;padding:2px 0;'></td>"
+ "<td style='width:20%;text-align:left;white-space:nowrap;" + labelStyle + "'>" + label + "</td>"
+ "<td style='width:25%;text-align:right;white-space:nowrap;" + valueStyle + "'>" + value + "</td>"
+ "</tr>";
}
private String escapeHtml(String input) {
if (input == null) {
return "";
}
return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("'", "&#x27;");
}
}

View File

@@ -0,0 +1,9 @@
package de.assecutor.pdftool.template;
/** Wird geworfen, wenn ein Canvas-Template nicht nach PDF gerendert werden kann. */
public class TemplateRenderException extends RuntimeException {
public TemplateRenderException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,122 @@
package de.assecutor.pdftool.template;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Baut die Positions-Variablen für das Rendern von Rechnungstemplates auf:
* {@code services.json} (eine Zeile je Position mit Name, Nettobetrag und
* USt-Satz) sowie die Summen {@code invoice.net_total/vat_total/gross_total}.
* Wird von der E-Rechnungs-Maske und dem Rechnungsgenerator gemeinsam genutzt.
*/
public final class TemplateVariables {
private static final Logger log = LoggerFactory.getLogger(TemplateVariables.class);
private static final BigDecimal HUNDRED = new BigDecimal("100");
private TemplateVariables() {
}
/** Ergänzt services.json, Summen und USt-Satz-Label aus den Positionen. */
public static void addPositions(Map<String, String> variables, List<InvoiceMetadata.LineItem> items) {
List<Map<String, String>> positions = new ArrayList<>();
BigDecimal netTotal = BigDecimal.ZERO;
BigDecimal vatTotal = BigDecimal.ZERO;
for (InvoiceMetadata.LineItem item : items) {
BigDecimal net = lineNet(item);
Map<String, String> position = new HashMap<>();
position.put("name", item.description());
position.put("netAmount", formatAmount(net));
position.put("vat", vatLabel(item.vatPercent()));
positions.add(position);
netTotal = netTotal.add(net);
vatTotal = vatTotal.add(lineVat(item));
}
BigDecimal grossTotal = netTotal.add(vatTotal);
try {
variables.put("services.json", new ObjectMapper().writeValueAsString(positions));
} catch (Exception ex) {
log.error("Positionen konnten nicht serialisiert werden: {}", ex.getMessage(), ex);
variables.put("services.json", "[]");
}
variables.put("services.count", String.valueOf(positions.size()));
variables.put("invoice.net_total", formatAmount(netTotal) + "");
variables.put("invoice.vat_total", formatAmount(vatTotal) + "");
variables.put("invoice.gross_total", formatAmount(grossTotal) + "");
variables.put("invoice.vat_rate", uniformVatLabel(items));
variables.put("services.net_total", formatAmount(netTotal) + "");
variables.put("services.gross_total", formatAmount(grossTotal) + "");
}
/** JSON für die Canvas-Darstellung des services.list-Bausteins. */
public static String canvasPositionsJson(List<InvoiceMetadata.LineItem> items) {
List<Map<String, String>> rows = new ArrayList<>();
BigDecimal netTotal = BigDecimal.ZERO;
BigDecimal vatTotal = BigDecimal.ZERO;
for (InvoiceMetadata.LineItem item : items) {
BigDecimal net = lineNet(item);
Map<String, String> row = new HashMap<>();
row.put("name", item.description());
row.put("vat", vatLabel(item.vatPercent()));
row.put("net", formatAmount(net) + "");
rows.add(row);
netTotal = netTotal.add(net);
vatTotal = vatTotal.add(lineVat(item));
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("rows", rows);
data.put("netTotal", formatAmount(netTotal) + "");
data.put("vatTotal", formatAmount(vatTotal) + "");
data.put("grossTotal", formatAmount(netTotal.add(vatTotal)) + "");
try {
return new ObjectMapper().writeValueAsString(data);
} catch (Exception ex) {
log.error("Canvas-Positionsdaten konnten nicht serialisiert werden: {}", ex.getMessage(), ex);
return "null";
}
}
/** Betrag mit zwei Nachkommastellen und Komma, z. B. "1200,00". */
public static String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
}
/** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */
public static String vatLabel(BigDecimal percent) {
BigDecimal normalized = percent.stripTrailingZeros();
if (normalized.scale() < 0) {
normalized = normalized.setScale(0);
}
return normalized.toPlainString().replace('.', ',') + "%";
}
private static BigDecimal lineNet(InvoiceMetadata.LineItem item) {
return item.quantity().multiply(item.unitPriceNet()).setScale(2, RoundingMode.HALF_UP);
}
private static BigDecimal lineVat(InvoiceMetadata.LineItem item) {
return lineNet(item).multiply(item.vatPercent()).divide(HUNDRED, 2, RoundingMode.HALF_UP);
}
/** Gemeinsamer USt-Satz aller Positionen oder leer bei gemischten Sätzen. */
private static String uniformVatLabel(List<InvoiceMetadata.LineItem> items) {
List<String> labels = items.stream().map(item -> vatLabel(item.vatPercent())).distinct().toList();
return labels.size() == 1 ? labels.get(0) : "";
}
}

View File

@@ -0,0 +1,248 @@
package de.assecutor.pdftool.ui;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.pdftool.address.Address;
import de.assecutor.pdftool.address.AddressRepository;
import de.assecutor.pdftool.address.AddressType;
import de.assecutor.pdftool.invoice.VatIdValidator;
import org.springframework.data.domain.Sort;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* Adressbuch: Übersicht aller Firmenadressen (Rechnungssteller und
* Rechnungsempfänger) mit Anlegen, Bearbeiten und Löschen.
*/
@Route(value = "adressbuch", layout = MainLayout.class)
@PageTitle("Adressbuch")
public class AddressBookView extends VerticalLayout {
private final AddressRepository repository;
private final Grid<Address> grid = new Grid<>(Address.class, false);
/** Filter über der Tabelle: alle Adressen oder nur ein Verwendungszweck. */
private final ComboBox<AddressType> typeFilter = new ComboBox<>();
public AddressBookView(AddressRepository repository) {
this.repository = repository;
setSizeFull();
typeFilter.setItems(AddressType.values());
typeFilter.setItemLabelGenerator(type -> type.getLabel());
typeFilter.setPlaceholder("Alle Adressen");
typeFilter.setClearButtonVisible(true);
typeFilter.setWidth("260px");
typeFilter.addValueChangeListener(event -> refreshGrid());
Button addAddress = new Button("Neue Adresse", new Icon(VaadinIcon.PLUS));
addAddress.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addAddress.addClickListener(event -> openEditDialog(null));
HorizontalLayout toolbar = new HorizontalLayout(typeFilter, addAddress);
toolbar.setWidthFull();
toolbar.setJustifyContentMode(JustifyContentMode.BETWEEN);
toolbar.setAlignItems(Alignment.CENTER);
add(toolbar);
grid.addComponentColumn(address -> {
boolean isSender = address.getType() == AddressType.RECHNUNGSSTELLER;
Icon icon = (isSender ? VaadinIcon.PAPERPLANE_O : VaadinIcon.INBOX).create();
icon.setSize("var(--lumo-icon-size-s)");
icon.getStyle().set("color", "var(--lumo-secondary-text-color)");
icon.getElement().setAttribute("title",
address.getType() != null ? address.getType().getLabel() : "");
return icon;
})
.setHeader("Typ")
.setSortable(true)
.setComparator(Comparator.comparing(
address -> address.getType() != null ? address.getType().getLabel() : ""))
.setFlexGrow(0)
.setAutoWidth(true);
grid.addColumn(address -> address.getName()).setHeader("Firma / Name").setSortable(true).setAutoWidth(true);
grid.addColumn(address -> address.getStreet()).setHeader("Straße").setAutoWidth(true);
grid.addComponentColumn(address -> {
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openEditDialog(address));
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
edit.setTooltipText("Bearbeiten");
Button delete = new Button(new Icon(VaadinIcon.TRASH), event -> confirmDelete(address));
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
ButtonVariant.LUMO_SMALL);
delete.setTooltipText("Löschen");
return new HorizontalLayout(edit, delete);
}).setHeader("").setFlexGrow(0).setAutoWidth(true);
grid.addItemDoubleClickListener(event -> openEditDialog(event.getItem()));
grid.setSizeFull();
add(grid);
expand(grid);
refreshGrid();
}
private void refreshGrid() {
AddressType filter = typeFilter.getValue();
List<Address> addresses = filter == null
? repository.findAll(Sort.by(Sort.Order.asc("type"), Sort.Order.asc("name")))
: repository.findByType(filter);
addresses = addresses.stream()
.sorted(Comparator
.comparing((Address address) -> address.getType() != null ? address.getType().ordinal() : 0)
.thenComparing(address -> address.getName() != null ? address.getName() : "",
String.CASE_INSENSITIVE_ORDER))
.toList();
grid.setItems(addresses);
}
/** Öffnet den Dialog zum Anlegen ({@code address == null}) bzw. Bearbeiten einer Adresse. */
private void openEditDialog(Address address) {
boolean isNew = address == null;
Address target = address == null ? new Address() : address;
Dialog dialog = new Dialog();
dialog.setHeaderTitle(isNew ? "Neue Adresse" : "Adresse bearbeiten");
ComboBox<AddressType> type = new ComboBox<>("Typ");
type.setItems(AddressType.values());
type.setItemLabelGenerator(value -> value.getLabel());
type.setRequired(true);
type.setValue(target.getType() != null ? target.getType()
: (typeFilter.getValue() != null ? typeFilter.getValue() : AddressType.RECHNUNGSEMPFAENGER));
TextField name = requiredField("Firma / Name", target.getName());
TextField street = requiredField("Straße und Hausnummer", target.getStreet());
TextField zip = requiredField("PLZ", target.getZip());
TextField city = requiredField("Ort", target.getCity());
TextField countryCode = requiredField("Land (ISO-Code, z.B. DE)",
target.getCountryCode() != null ? target.getCountryCode() : "DE");
countryCode.setMaxLength(2);
countryCode.setPattern("[A-Za-z]{2}");
TextField vatId = new TextField("USt-IdNr.");
vatId.setValue(target.getVatId() != null ? target.getVatId() : "");
vatId.setHelperText("z. B. DE261094748");
EmailField email = new EmailField("E-Mail");
email.setValue(target.getEmail() != null ? target.getEmail() : "");
TextField phone = new TextField("Telefon");
phone.setValue(target.getPhone() != null ? target.getPhone() : "");
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone);
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2));
form.setWidth("560px");
dialog.add(form);
Button cancel = new Button("Abbrechen", event -> dialog.close());
Button save = new Button("Speichern", event -> {
List<String> errors = new ArrayList<>();
if (type.getValue() == null) {
type.setInvalid(true);
errors.add("Typ ist ein Pflichtfeld.");
}
validateRequired(name, "Firma / Name", errors);
validateRequired(street, "Straße und Hausnummer", errors);
validateRequired(zip, "PLZ", errors);
validateRequired(city, "Ort", errors);
if (validateRequired(countryCode, "Land", errors)
&& !countryCode.getValue().trim().matches("[A-Za-z]{2}")) {
countryCode.setErrorMessage("Land muss ein zweistelliger ISO-Code sein.");
countryCode.setInvalid(true);
errors.add("Land muss ein zweistelliger ISO-Code sein.");
}
if (!vatId.getValue().isBlank()) {
Optional<String> vatError = VatIdValidator.validate(vatId.getValue());
if (vatError.isPresent()) {
vatId.setErrorMessage(vatError.get());
vatId.setInvalid(true);
errors.add(vatError.get());
}
}
if (!email.getValue().isBlank() && !email.getValue().matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) {
email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
email.setInvalid(true);
errors.add("E-Mail-Adresse ist ungültig.");
}
if (!errors.isEmpty()) {
Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
target.setType(type.getValue());
target.setName(name.getValue().trim());
target.setStreet(street.getValue().trim());
target.setZip(zip.getValue().trim());
target.setCity(city.getValue().trim());
target.setCountryCode(countryCode.getValue().trim().toUpperCase());
target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue()));
target.setEmail(email.getValue().trim());
target.setPhone(phone.getValue().trim());
target.touch();
repository.save(target);
dialog.close();
refreshGrid();
Notification.show(isNew ? "Adresse angelegt." : "Adresse gespeichert.", 3000,
Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
});
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancel, save);
dialog.open();
name.focus();
}
private void confirmDelete(Address address) {
ConfirmDialog confirm = new ConfirmDialog();
confirm.setHeader("Adresse löschen?");
confirm.setText("Soll die Adresse \"" + address.getName() + "\" wirklich gelöscht werden?");
confirm.setConfirmText("Löschen");
confirm.setConfirmButtonTheme("error primary");
confirm.setCancelable(true);
confirm.setCancelText("Abbrechen");
confirm.addConfirmListener(event -> {
repository.delete(address);
refreshGrid();
Notification.show("Adresse gelöscht.", 3000, Notification.Position.BOTTOM_START);
});
confirm.open();
}
private static TextField requiredField(String label, String value) {
TextField field = new TextField(label);
field.setRequired(true);
field.setValue(value != null ? value : "");
return field;
}
/** @return true, wenn das Pflichtfeld gefüllt ist; markiert es sonst als ungültig. */
private static boolean validateRequired(TextField field, String label, List<String> errors) {
if (field.getValue() == null || field.getValue().isBlank()) {
field.setErrorMessage(label + " ist ein Pflichtfeld.");
field.setInvalid(true);
errors.add(label + " ist ein Pflichtfeld.");
return false;
}
field.setInvalid(false);
return true;
}
}

View File

@@ -0,0 +1,59 @@
package de.assecutor.pdftool.ui;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.theme.lumo.LumoUtility;
/**
* Rahmenlayout der Anwendung mit linker Sidebar: Navigation zwischen der
* E-Rechnungs-Erzeugung, dem Rechnungsgenerator (Template-Editor) und dem
* Adressbuch. Der Titel der aktiven Seite wird oben in der Navbar neben dem
* Hamburger-Menü angezeigt.
*/
public class MainLayout extends AppLayout {
/** Titel der aktiven Seite in der Navbar. */
private final H2 viewTitle = new H2();
public MainLayout() {
setPrimarySection(Section.DRAWER);
H1 appName = new H1("AIT");
appName.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.Vertical.MEDIUM);
appName.setWidth("100%");
appName.getStyle().set("text-align", "center");
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("E-Rechnung erzeugen", MainView.class, VaadinIcon.INVOICE.create()));
nav.addItem(new SideNavItem("Templates", TemplateGeneratorView.class,
VaadinIcon.FILE_PROCESS.create()));
nav.addItem(new SideNavItem("Adressbuch", AddressBookView.class, VaadinIcon.BOOK.create()));
Scroller scroller = new Scroller(nav);
scroller.addClassNames(LumoUtility.Padding.SMALL);
viewTitle.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
addToDrawer(appName, scroller);
addToNavbar(new DrawerToggle(), viewTitle);
}
@Override
protected void afterNavigation() {
super.afterNavigation();
viewTitle.setText(getCurrentPageTitle());
}
/** @return der Titel der aktiven View aus deren {@code @PageTitle}-Annotation. */
private String getCurrentPageTitle() {
PageTitle title = getContent().getClass().getAnnotation(PageTitle.class);
return title != null ? title.value() : "";
}
}

View File

@@ -0,0 +1,847 @@
package de.assecutor.pdftool.ui;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.ColumnTextAlign;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.BigDecimalField;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import de.assecutor.pdftool.address.Address;
import de.assecutor.pdftool.address.AddressRepository;
import de.assecutor.pdftool.address.AddressType;
import de.assecutor.pdftool.invoice.CapturedInvoiceData;
import de.assecutor.pdftool.invoice.InvoiceDraft;
import de.assecutor.pdftool.invoice.InvoiceDraftService;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import de.assecutor.pdftool.invoice.VatIdValidator;
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 java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@Route(value = "", layout = MainLayout.class)
@PageTitle("E-Rechnung erzeugen")
public class MainView extends VerticalLayout {
private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100");
private final ZugferdService zugferdService;
private final CapturedInvoiceData capturedInvoiceData;
private final InvoiceTemplateService invoiceTemplateService;
private final TemplatePdfService templatePdfService;
private final MemoryBuffer uploadBuffer = new MemoryBuffer();
private byte[] uploadedPdf;
/** Alternative zum PDF-Upload: ein gespeichertes Rechnungstemplate. */
private final ComboBox<String> templateSelect = new ComboBox<>("Rechnungstemplate");
/** Vorbelegung der Rechnungssteller-Felder aus dem Adressbuch. */
private final ComboBox<Address> senderAddressSelect = new ComboBox<>("Aus dem Adressbuch übernehmen");
/** Vorbelegung der Rechnungsempfänger-Felder aus dem Adressbuch. */
private final ComboBox<Address> recipientAddressSelect = new ComboBox<>("Aus dem Adressbuch übernehmen");
private final AddressRepository addressRepository;
private final InvoiceDraftService invoiceDraftService;
/** Laden zuvor gespeicherter Eingaben dieser Maske. */
private final ComboBox<String> draftSelect = new ComboBox<>("Gespeicherte Eingaben");
private final Button generate = new Button("E-Rechnung erzeugen", event -> generate());
private final TextField invoiceNumber = new TextField("Rechnungsnummer");
private final DatePicker issueDate = new DatePicker("Rechnungsdatum");
private final DatePicker deliveryDate = new DatePicker("Lieferdatum");
private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum");
private final TextField currency = new TextField("Währung");
private final TextField paymentTerms = new TextField("Zahlungsbedingungen");
private final TextField buyerReference = new TextField("Käuferreferenz / Leitweg-ID");
private final TextField iban = new TextField("IBAN (Rechnungssteller)");
private final TextField bic = new TextField("BIC");
private final PartyForm sender = new PartyForm(true);
private final PartyForm recipient = new PartyForm(false);
/** Die erfassten Rechnungspositionen, dargestellt in einer Tabelle. */
private final List<InvoiceMetadata.LineItem> lineItems = new ArrayList<>();
private final Grid<InvoiceMetadata.LineItem> itemsGrid = new Grid<>();
private final Anchor downloadLink = new Anchor();
/**
* Alle Controls unterhalb der Quellenwahl; sie sind erst aktiv, wenn ein PDF
* hochgeladen oder ein Template ausgewählt wurde.
*/
private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>();
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData,
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService,
Environment environment) {
this.zugferdService = zugferdService;
this.capturedInvoiceData = capturedInvoiceData;
this.invoiceTemplateService = invoiceTemplateService;
this.templatePdfService = templatePdfService;
this.addressRepository = addressRepository;
this.invoiceDraftService = invoiceDraftService;
setMaxWidth("960px");
add(new Paragraph("Laden Sie ein Rechnungs-PDF hoch oder wählen Sie ein gespeichertes "
+ "Rechnungstemplate und ergänzen Sie die Rechnungsdaten. Die Rechnung wird als "
+ "rechtskonforme ZUGFeRD-Rechnung (PDF/A-3 mit eingebettetem EN16931-XML) erzeugt "
+ "und als ZIP-Datei bereitgestellt."));
// Eingaben unter einem Namen speichern bzw. gespeicherte Eingaben laden.
draftSelect.setPlaceholder("Eingaben laden");
draftSelect.setClearButtonVisible(true);
draftSelect.setWidth("300px");
draftSelect.addValueChangeListener(event -> {
if (event.isFromClient() && event.getValue() != null) {
loadDraft(event.getValue());
}
});
Button saveDraft = new Button("Eingaben speichern", event -> openSaveDraftDialog());
HorizontalLayout draftBar = new HorizontalLayout(draftSelect, saveDraft);
draftBar.setAlignItems(Alignment.END);
add(draftBar);
Upload upload = new Upload(uploadBuffer);
upload.setAcceptedFileTypes("application/pdf", ".pdf");
upload.setMaxFiles(1);
upload.setMaxFileSize(25 * 1024 * 1024);
upload.addSucceededListener(event -> {
try {
uploadedPdf = uploadBuffer.getInputStream().readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
// Entweder PDF oder Template: hochgeladenes PDF hebt die Template-Auswahl auf.
templateSelect.clear();
updateControlsEnabled();
});
upload.addFileRemovedListener(event -> {
uploadedPdf = null;
updateControlsEnabled();
});
templateSelect.setItems(invoiceTemplateService.templateNames());
templateSelect.setPlaceholder("Template auswählen");
templateSelect.setClearButtonVisible(true);
templateSelect.setWidth("300px");
templateSelect.addValueChangeListener(event -> {
if (event.isFromClient() && event.getValue() != null) {
// Entweder PDF oder Template: Auswahl entfernt ein hochgeladenes PDF.
upload.clearFileList();
uploadedPdf = null;
// Rechnungssteller-Felder leer beginnen lassen.
senderAddressSelect.clear();
sender.clearFields();
}
updateControlsEnabled();
});
HorizontalLayout sourceLayout = new HorizontalLayout(upload, templateSelect);
sourceLayout.setAlignItems(Alignment.CENTER);
// Das Label der ComboBox ausgleichen, damit das Eingabefeld selbst
// mittig zur Upload-Fläche steht.
templateSelect.getStyle().set("margin-top", "-1.25em");
add(new H3("1. Rechnungs-PDF hochladen oder Template auswählen"), sourceLayout);
invoiceNumber.setRequired(true);
invoiceNumber.setErrorMessage("Rechnungsnummer ist ein Pflichtfeld.");
issueDate.setRequired(true);
issueDate.setErrorMessage("Rechnungsdatum ist ein Pflichtfeld.");
issueDate.setValue(LocalDate.now());
currency.setRequired(true);
currency.setMaxLength(3);
currency.setPattern("[A-Za-z]{3}");
currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR.");
currency.setValue("EUR");
buyerReference.setHelperText("BT-10 — für Rechnungen an Behörden erforderlich");
iban.setHelperText("Für die Zahlungsangaben (BG-16) empfohlen");
FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency,
paymentTerms, buyerReference, iban, bic);
invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3));
add(new H3("2. Rechnungsdaten"), invoiceForm);
configureAddressSelect(senderAddressSelect, sender);
configureAddressSelect(recipientAddressSelect, recipient);
add(new H3("3. Rechnungssteller"), senderAddressSelect, sender);
add(new H3("4. Rechnungsempfänger"), recipientAddressSelect, recipient);
configureItemsGrid();
Button addItem = new Button("Position hinzufügen", new Icon(VaadinIcon.PLUS),
event -> openItemDialog(null));
add(new H3("5. Rechnungspositionen (netto)"), itemsGrid, addItem);
generate.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
sourceDependentControls.addAll(List.of(invoiceForm, senderAddressSelect, sender,
recipientAddressSelect, recipient, itemsGrid, addItem, generate));
updateControlsEnabled();
downloadLink.getElement().setAttribute("download", true);
downloadLink.setVisible(false);
add(new HorizontalLayout(generate, downloadLink));
if (environment.acceptsProfiles(Profiles.of("dev"))) {
prefillDevData();
}
}
@Override
protected void onAttach(com.vaadin.flow.component.AttachEvent attachEvent) {
super.onAttach(attachEvent);
// Adressen bei jedem Anzeigen der Seite frisch laden, damit neu angelegte
// Adressbuch-Einträge ohne Neustart sichtbar sind.
senderAddressSelect.setItems(addressRepository.findByType(AddressType.RECHNUNGSSTELLER));
recipientAddressSelect.setItems(addressRepository.findByType(AddressType.RECHNUNGSEMPFAENGER));
draftSelect.setItems(invoiceDraftService.names());
}
/** Fragt den Namen ab, unter dem die Eingaben gespeichert werden sollen. */
private void openSaveDraftDialog() {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Eingaben speichern");
TextField nameField = new TextField("Name");
nameField.setValue(draftSelect.getValue() != null ? draftSelect.getValue()
: invoiceNumber.getValue().trim());
nameField.setWidth("300px");
dialog.add(nameField);
Button cancel = new Button("Abbrechen", event -> dialog.close());
Button save = new Button("Speichern", event -> {
String name = nameField.getValue() == null ? "" : nameField.getValue().trim();
if (name.isEmpty()) {
nameField.setErrorMessage("Bitte einen Namen angeben.");
nameField.setInvalid(true);
return;
}
dialog.close();
if (invoiceDraftService.exists(name)) {
confirmOverwriteDraft(name);
} else {
saveDraft(name);
}
});
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancel, save);
dialog.open();
nameField.focus();
}
/** Rückfrage, ob die bereits vorhandenen Eingaben gleichen Namens überschrieben werden sollen. */
private void confirmOverwriteDraft(String name) {
ConfirmDialog confirm = new ConfirmDialog();
confirm.setHeader("Eingaben überschreiben?");
confirm.setText("Es sind bereits Eingaben mit dem Namen \"" + name
+ "\" gespeichert. Sollen sie überschrieben werden?");
confirm.setConfirmText("Überschreiben");
confirm.setCancelable(true);
confirm.setCancelText("Abbrechen");
confirm.addConfirmListener(event -> saveDraft(name));
confirm.addCancelListener(event -> openSaveDraftDialog());
confirm.open();
}
private void saveDraft(String name) {
invoiceDraftService.save(buildDraft(name));
draftSelect.setItems(invoiceDraftService.names());
draftSelect.setValue(name);
Notification.show("Eingaben \"" + name + "\" gespeichert.", 3000, Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
/** Sammelt den aktuellen Formularstand; ein hochgeladenes PDF wird nicht mitgespeichert. */
private InvoiceDraft buildDraft(String name) {
return new InvoiceDraft(null, name,
templateSelect.getValue(),
invoiceNumber.getValue().trim(),
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
currency.getValue().trim(),
paymentTerms.getValue(),
buyerReference.getValue().trim(),
iban.getValue().trim(),
bic.getValue().trim(),
sender.toParty(), recipient.toParty(),
List.copyOf(lineItems),
LocalDateTime.now());
}
private void loadDraft(String name) {
Optional<InvoiceDraft> draft = invoiceDraftService.load(name);
if (draft.isEmpty()) {
showErrors(List.of("Die gespeicherten Eingaben \"" + name + "\" konnten nicht geladen werden."));
return;
}
applyDraft(draft.get());
Notification.show("Eingaben \"" + name + "\" geladen.", 3000, Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
/** Überträgt gespeicherte Eingaben in das Formular. */
private void applyDraft(InvoiceDraft draft) {
// Die Template-Auswahl wiederherstellen, sofern das Template noch existiert;
// ein hochgeladenes PDF ist nicht Teil der gespeicherten Eingaben.
if (draft.templateName() != null && !draft.templateName().isBlank()
&& invoiceTemplateService.templateExists(draft.templateName())) {
templateSelect.setValue(draft.templateName());
}
invoiceNumber.setValue(nullToEmpty(draft.invoiceNumber()));
issueDate.setValue(draft.issueDate());
deliveryDate.setValue(draft.deliveryDate());
dueDate.setValue(draft.dueDate());
currency.setValue(nullToEmpty(draft.currency()));
paymentTerms.setValue(nullToEmpty(draft.paymentTerms()));
buyerReference.setValue(nullToEmpty(draft.buyerReference()));
iban.setValue(nullToEmpty(draft.iban()));
bic.setValue(nullToEmpty(draft.bic()));
applyParty(sender, draft.sender());
applyParty(recipient, draft.recipient());
lineItems.clear();
if (draft.items() != null) {
lineItems.addAll(draft.items());
}
refreshItemsGrid();
updateControlsEnabled();
}
private void applyParty(PartyForm form, InvoiceMetadata.Party party) {
if (party == null) {
form.clearFields();
return;
}
form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()),
nullToEmpty(party.zip()), nullToEmpty(party.city()),
nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()),
nullToEmpty(party.email()), nullToEmpty(party.phone()));
}
/** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */
private void configureAddressSelect(ComboBox<Address> select, PartyForm form) {
select.setItemLabelGenerator(address -> address.getName());
select.setPlaceholder("Adresse auswählen");
select.setClearButtonVisible(true);
select.setWidth("300px");
select.addValueChangeListener(event -> {
Address address = event.getValue();
if (address != null) {
form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()),
nullToEmpty(address.getZip()), nullToEmpty(address.getCity()),
nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()),
nullToEmpty(address.getEmail()), nullToEmpty(address.getPhone()));
}
});
}
@Override
protected void onDetach(com.vaadin.flow.component.DetachEvent detachEvent) {
// Erfasste Positionen für den Rechnungsgenerator bereitstellen,
// z. B. beim Wechsel über die Sidebar.
capturedInvoiceData.setItems(snapshotItems());
super.onDetach(detachEvent);
}
/** Übernimmt alle erfassten Positionen. */
private List<InvoiceMetadata.LineItem> snapshotItems() {
return List.copyOf(lineItems);
}
/** Belegt das Formular im dev-Profil mit Testdaten vor, um manuelles Tippen zu sparen. */
private void prefillDevData() {
invoiceNumber.setValue("11111");
deliveryDate.setValue(LocalDate.now());
dueDate.setValue(LocalDate.now().plusDays(14));
paymentTerms.setValue("14 Tage");
buyerReference.setValue("11111");
iban.setValue("DE64 6001 0070 0379 0907 00");
bic.setValue("PBNKDEFFXXX");
// Rechnungssteller und -empfänger bleiben leer und werden über die
// Adressbuch-Auswahl befüllt.
lineItems.add(new InvoiceMetadata.LineItem("sdf", BigDecimal.ONE, new BigDecimal("100"),
new BigDecimal("19")));
refreshItemsGrid();
}
/** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */
private void configureItemsGrid() {
itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1);
itemsGrid.addColumn(item -> formatDecimal(item.quantity())).setHeader("Menge")
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
itemsGrid.addColumn(item -> formatDecimal(item.unitPriceNet())).setHeader("Einzelpreis netto")
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
itemsGrid.addColumn(item -> formatDecimal(item.vatPercent())).setHeader("USt %")
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
itemsGrid.addComponentColumn(item -> {
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openItemDialog(item));
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
edit.setTooltipText("Bearbeiten");
Button delete = new Button(new Icon(VaadinIcon.TRASH), event -> {
lineItems.remove(item);
refreshItemsGrid();
});
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
ButtonVariant.LUMO_SMALL);
delete.setTooltipText("Löschen");
return new HorizontalLayout(edit, delete);
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
itemsGrid.addItemDoubleClickListener(event -> openItemDialog(event.getItem()));
itemsGrid.setAllRowsVisible(true);
itemsGrid.setWidthFull();
}
private void refreshItemsGrid() {
itemsGrid.setItems(List.copyOf(lineItems));
}
/** Dialog zum Anlegen ({@code existing == null}) bzw. Bearbeiten einer Rechnungsposition. */
private void openItemDialog(InvoiceMetadata.LineItem existing) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle(existing == null ? "Position hinzufügen" : "Position bearbeiten");
TextField description = new TextField("Beschreibung");
description.setRequired(true);
description.setValue(existing != null ? existing.description() : "");
description.setWidthFull();
BigDecimalField quantity = new BigDecimalField("Menge");
quantity.setRequiredIndicatorVisible(true);
quantity.setValue(existing != null ? existing.quantity() : BigDecimal.ONE);
BigDecimalField unitPrice = new BigDecimalField("Einzelpreis netto");
unitPrice.setRequiredIndicatorVisible(true);
unitPrice.setValue(existing != null ? existing.unitPriceNet() : null);
BigDecimalField vatPercent = new BigDecimalField("USt %");
vatPercent.setRequiredIndicatorVisible(true);
vatPercent.setValue(existing != null ? existing.vatPercent() : new BigDecimal("19"));
FormLayout form = new FormLayout(description, quantity, unitPrice, vatPercent);
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 3));
form.setColspan(description, 3);
form.setWidth("560px");
dialog.add(form);
Button cancel = new Button("Abbrechen", event -> dialog.close());
Button save = new Button("Speichern", event -> {
List<String> errors = new ArrayList<>();
requireFilled(description, "Beschreibung ist ein Pflichtfeld.", errors);
if (quantity.getValue() == null || quantity.getValue().signum() <= 0) {
markInvalid(quantity, "Menge muss größer 0 sein.", errors);
} else {
quantity.setInvalid(false);
}
if (unitPrice.getValue() == null) {
markInvalid(unitPrice, "Einzelpreis ist ein Pflichtfeld.", errors);
} else {
unitPrice.setInvalid(false);
}
if (vatPercent.getValue() == null || vatPercent.getValue().signum() < 0
|| vatPercent.getValue().compareTo(MAX_VAT_PERCENT) > 0) {
markInvalid(vatPercent, "USt-Satz muss zwischen 0 und 100 liegen.", errors);
} else {
vatPercent.setInvalid(false);
}
if (!errors.isEmpty()) {
showErrors(errors);
return;
}
InvoiceMetadata.LineItem item = new InvoiceMetadata.LineItem(description.getValue().trim(),
quantity.getValue(), unitPrice.getValue(), vatPercent.getValue());
int index = existing != null ? lineItems.indexOf(existing) : -1;
if (index >= 0) {
lineItems.set(index, item);
} else {
lineItems.add(item);
}
refreshItemsGrid();
dialog.close();
});
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancel, save);
dialog.open();
description.focus();
}
/**
* Das Rechnungsformular und der Erzeugen-Button sind nur aktiv, wenn ein
* PDF hochgeladen oder ein Template ausgewählt wurde.
*/
private void updateControlsEnabled() {
boolean sourceSelected = uploadedPdf != null || templateSelect.getValue() != null;
sourceDependentControls.forEach(control -> control.setEnabled(sourceSelected));
}
private void generate() {
downloadLink.setVisible(false);
List<String> errors = validateForm();
if (!errors.isEmpty()) {
showErrors(errors);
return;
}
if (uploadedPdf != null) {
convertAndOfferDownload(uploadedPdf);
return;
}
String templateName = templateSelect.getValue();
Optional<String> templateData = invoiceTemplateService.loadTemplate(templateName);
if (templateData.isEmpty()) {
showErrors(List.of("Das Template \"" + templateName + "\" konnte nicht geladen werden."));
return;
}
generateFromTemplate(templateData.get());
}
/** Rendert das Rechnungstemplate mit den Formulardaten und wandelt das Ergebnis in eine E-Rechnung um. */
private void generateFromTemplate(String templateData) {
try {
InvoiceMetadata metadata = collectMetadata();
byte[] templatePdf = templatePdfService.generatePdf(templateData, buildTemplateVariables(metadata), null);
convertAndOfferDownload(templatePdf);
} catch (TemplateRenderException e) {
showErrors(List.of(e.getMessage()));
}
}
/** Belegt die Template-Variablen (masterdata.*, customer.*, Positionen) mit den Formulardaten. */
private Map<String, String> buildTemplateVariables(InvoiceMetadata metadata) {
Map<String, String> variables = new LinkedHashMap<>();
InvoiceMetadata.Party senderParty = metadata.sender();
variables.put("masterdata.company_name", senderParty.name());
variables.put("masterdata.street", senderParty.street());
variables.put("masterdata.city", senderParty.zip() + " " + senderParty.city());
variables.put("masterdata.phone", senderParty.phone());
variables.put("masterdata.email", senderParty.email());
variables.put("masterdata.website", "");
variables.put("masterdata.sender_line",
senderParty.name() + " · " + senderParty.street() + " · " + senderParty.zip() + " " + senderParty.city());
variables.put("masterdata.payment_terms", metadata.paymentTerms());
StringBuilder footer = new StringBuilder(senderParty.name());
if (!senderParty.vatId().isEmpty()) {
footer.append(" · USt-IdNr. ").append(senderParty.vatId());
}
if (!metadata.iban().isEmpty()) {
footer.append("\nIBAN ").append(metadata.iban());
if (!metadata.bic().isEmpty()) {
footer.append(" · BIC ").append(metadata.bic());
}
}
variables.put("masterdata.footer", footer.toString());
variables.put("masterdata.invoice_number", metadata.invoiceNumber());
variables.put("masterdata.invoice_date",
metadata.issueDate().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
InvoiceMetadata.Party recipientParty = metadata.recipient();
variables.put("customer.company_name", recipientParty.name());
variables.put("customer.contact_name", "");
variables.put("customer.street", recipientParty.street());
variables.put("customer.city", recipientParty.zip() + " " + recipientParty.city());
variables.put("customer.email", recipientParty.email());
variables.put("customer.phone", recipientParty.phone());
TemplateVariables.addPositions(variables, metadata.items());
return variables;
}
/** Wandelt das Quell-PDF 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);
StreamResource resource = new StreamResource(result.zipFileName(),
() -> new ByteArrayInputStream(result.zip()));
resource.setContentType("application/zip");
downloadLink.setHref(resource);
downloadLink.setText("ZIP herunterladen (" + result.zipFileName() + ")");
downloadLink.setVisible(true);
if (result.valid()) {
Notification.show("E-Rechnung erzeugt und erfolgreich validiert.", 4000,
Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} else {
Notification.show("E-Rechnung erzeugt, aber die Validierung meldet Probleme. "
+ "Details stehen im Prüfbericht (validation-report.xml) im ZIP.", 8000,
Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_WARNING);
}
} catch (ZugferdConversionException e) {
showErrors(List.of(e.getMessage()));
}
}
/** Prüft alle Eingaben, markiert ungültige Felder und liefert die Fehlermeldungen. */
private List<String> validateForm() {
List<String> errors = new ArrayList<>();
requireFilled(invoiceNumber, "Rechnungsnummer ist ein Pflichtfeld.", errors);
if (issueDate.isEmpty()) {
issueDate.setInvalid(true);
errors.add("Rechnungsdatum ist ein Pflichtfeld.");
} else {
issueDate.setInvalid(false);
}
if (requireFilled(currency, "Währung ist ein Pflichtfeld.", errors)
&& !currency.getValue().trim().matches("[A-Za-z]{3}")) {
markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors);
}
if (!iban.isEmpty() && !isValidIban(iban.getValue())) {
markInvalid(iban, "IBAN ist ungültig (Prüfsumme).", errors);
} else {
iban.setInvalid(false);
}
if (!bic.isEmpty() && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
markInvalid(bic, "BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
} else {
bic.setInvalid(false);
}
errors.addAll(sender.validateFields("Rechnungssteller"));
errors.addAll(recipient.validateFields("Rechnungsempfänger"));
if (lineItems.isEmpty()) {
errors.add("Mindestens eine Rechnungsposition erfassen.");
}
return errors;
}
private InvoiceMetadata collectMetadata() {
return new InvoiceMetadata(
invoiceNumber.getValue().trim(),
issueDate.getValue(),
deliveryDate.getValue(),
dueDate.getValue(),
currency.getValue().trim().toUpperCase(),
paymentTerms.getValue(),
buyerReference.getValue().trim(),
iban.getValue().replaceAll("\\s", "").toUpperCase(),
bic.getValue().trim().toUpperCase(),
sender.toParty(),
recipient.toParty(),
lineItems
);
}
private static String nullToEmpty(String value) {
return value != null ? value : "";
}
/** Formatiert einen Betrag bzw. eine Zahl für die Positionstabelle (deutsches Format). */
private static String formatDecimal(BigDecimal value) {
return value != null ? NumberFormat.getNumberInstance(Locale.GERMANY).format(value) : "";
}
/** Markiert das Feld als ungültig, wenn es leer ist, und ergänzt die Fehlermeldung. */
private static boolean requireFilled(TextField field, String message, List<String> errors) {
if (field.getValue() == null || field.getValue().isBlank()) {
markInvalid(field, message, errors);
return false;
}
field.setInvalid(false);
return true;
}
private static void markInvalid(TextField field, String message, List<String> errors) {
field.setErrorMessage(message);
field.setInvalid(true);
errors.add(message);
}
private static void markInvalid(BigDecimalField field, String message, List<String> errors) {
field.setErrorMessage(message);
field.setInvalid(true);
errors.add(message);
}
/** IBAN-Prüfung nach ISO 13616 (MOD 97-10). */
private static boolean isValidIban(String value) {
String normalized = value.replaceAll("\\s", "").toUpperCase();
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
return false;
}
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
int mod = 0;
for (char c : rearranged.toCharArray()) {
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
}
return mod == 1;
}
private void showErrors(List<String> messages) {
Notification notification = new Notification();
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
notification.setPosition(Notification.Position.MIDDLE);
notification.setDuration(8000);
Div text = new Div();
text.getStyle().set("white-space", "pre-line");
text.setText(String.join("\n", messages));
notification.add(text);
notification.open();
}
/**
* Adressformular für Rechnungssteller bzw. -empfänger.
*/
private static class PartyForm extends FormLayout {
private final boolean vatRequired;
private final TextField name = new TextField("Firma / Name");
private final TextField street = new TextField("Straße und Hausnummer");
private final TextField zip = new TextField("PLZ");
private final TextField city = new TextField("Ort");
private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)");
private final TextField vatId = new TextField("USt-IdNr.");
private final EmailField email = new EmailField("E-Mail");
private final TextField phone = new TextField("Telefon");
PartyForm(boolean vatRequired) {
this.vatRequired = vatRequired;
name.setRequired(true);
street.setRequired(true);
zip.setRequired(true);
city.setRequired(true);
countryCode.setRequired(true);
countryCode.setValue("DE");
countryCode.setMaxLength(2);
countryCode.setPattern("[A-Za-z]{2}");
countryCode.setErrorMessage("Zweistelliger ISO-Code, z. B. DE.");
vatId.setRequired(vatRequired);
vatId.setHelperText("z. B. DE261094748");
// USt-IdNr. direkt beim Verlassen des Feldes prüfen
vatId.addValueChangeListener(event -> checkVatId());
// Elektronische Adresse (BT-34/BT-49) ist nach PEPPOL-EN16931 Pflicht
email.setRequiredIndicatorVisible(true);
email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
if (vatRequired) {
phone.setHelperText("Für den Verkäufer-Kontakt (BR-DE-6) empfohlen");
}
add(name, street, zip, city, countryCode, vatId, email, phone);
setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3));
}
void prefill(String nameValue, String streetValue, String zipValue, String cityValue,
String countryValue, String vatIdValue, String emailValue, String phoneValue) {
name.setValue(nameValue);
street.setValue(streetValue);
zip.setValue(zipValue);
city.setValue(cityValue);
countryCode.setValue(countryValue);
vatId.setValue(vatIdValue);
email.setValue(emailValue);
phone.setValue(phoneValue);
}
/** Leert alle Felder; das Land behält den Standardwert "DE". */
void clearFields() {
prefill("", "", "", "", "DE", "", "", "");
name.setInvalid(false);
street.setInvalid(false);
zip.setInvalid(false);
city.setInvalid(false);
countryCode.setInvalid(false);
vatId.setInvalid(false);
email.setInvalid(false);
}
/** @return true, wenn die USt-IdNr. gültig (oder leer) ist. */
private boolean checkVatId() {
String value = vatId.getValue();
if (value == null || value.isBlank()) {
vatId.setInvalid(false);
return true;
}
Optional<String> error = VatIdValidator.validate(value);
vatId.setErrorMessage(error.orElse(null));
vatId.setInvalid(error.isPresent());
return error.isEmpty();
}
List<String> validateFields(String partyLabel) {
List<String> errors = new ArrayList<>();
requireFilled(name, partyLabel + ": Firma / Name ist ein Pflichtfeld.", errors);
requireFilled(street, partyLabel + ": Straße und Hausnummer ist ein Pflichtfeld.", errors);
requireFilled(zip, partyLabel + ": PLZ ist ein Pflichtfeld.", errors);
requireFilled(city, partyLabel + ": Ort ist ein Pflichtfeld.", errors);
if (requireFilled(countryCode, partyLabel + ": Land ist ein Pflichtfeld.", errors)
&& !countryCode.getValue().trim().matches("[A-Za-z]{2}")) {
markInvalid(countryCode, partyLabel + ": Land muss ein zweistelliger ISO-Code sein.", errors);
}
if (vatRequired && vatId.isEmpty()) {
markInvalid(vatId, partyLabel + ": USt-IdNr. ist ein Pflichtfeld.", errors);
} else if (!checkVatId()) {
errors.add(partyLabel + ": " + vatId.getErrorMessage());
}
if (email.isEmpty()) {
email.setErrorMessage(partyLabel + ": E-Mail ist ein Pflichtfeld.");
email.setInvalid(true);
errors.add(partyLabel + ": E-Mail ist ein Pflichtfeld (elektronische Adresse der E-Rechnung).");
} else if (!email.getValue().matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) {
email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
email.setInvalid(true);
errors.add(partyLabel + ": E-Mail-Adresse ist ungültig.");
} else {
email.setInvalid(false);
}
return errors;
}
InvoiceMetadata.Party toParty() {
return new InvoiceMetadata.Party(
name.getValue().trim(),
street.getValue().trim(),
zip.getValue().trim(),
city.getValue().trim(),
countryCode.getValue().trim().toUpperCase(),
vatId.isEmpty() ? "" : VatIdValidator.normalize(vatId.getValue()),
email.getValue().trim(),
phone.getValue().trim()
);
}
}
}

View File

@@ -0,0 +1,900 @@
package de.assecutor.pdftool.ui;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.ClientCallable;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.IFrame;
import com.vaadin.flow.component.html.Input;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.pdftool.invoice.CapturedInvoiceData;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import de.assecutor.pdftool.template.InvoiceTemplateService;
import de.assecutor.pdftool.template.TemplatePdfService;
import de.assecutor.pdftool.template.TemplateVariables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Canvas-Designer für eigene Rechnungstemplates: Bausteine (Stammdaten,
* Empfänger, Positionen, freie Elemente) werden per Drag &amp; Drop auf einer
* A4-Zeichenfläche platziert. Das Template wird als JSON gespeichert und kann
* als PDF-Vorschau gerendert werden. Portiert aus votianlt
* (InvoiceGeneratorView); Rechnungssteller-Stammdaten sind hier fest die
* Assecutor Data Service GmbH.
*/
@Route(value = "rechnungsgenerator", layout = MainLayout.class)
@PageTitle("Templates")
@JsModule("./invoice-generator/profile-invoice-generator.js")
@CssImport("./styles/invoice-generator.css")
public class TemplateGeneratorView extends VerticalLayout {
private static final Logger log = LoggerFactory.getLogger(TemplateGeneratorView.class);
private static final BigDecimal VAT_RATE = new BigDecimal("0.19");
private final TemplatePdfService templatePdfService;
private final InvoiceTemplateService invoiceTemplateService;
private final CapturedInvoiceData capturedInvoiceData;
private final VerticalLayout propertiesPanel;
/** Auswahl des zu bearbeitenden Templates in der Aktionsleiste. */
private ComboBox<String> templateSelect;
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */
private String currentTemplateName;
public TemplateGeneratorView(TemplatePdfService templatePdfService,
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
this.templatePdfService = templatePdfService;
this.invoiceTemplateService = invoiceTemplateService;
this.capturedInvoiceData = capturedInvoiceData;
addClassName("invoice-generator-view");
setSpacing(false);
setPadding(true);
setMargin(false);
setWidth("100%");
setHeight("100%");
getStyle().set("overflow", "hidden").set("box-sizing", "border-box").set("display", "flex")
.set("flex-direction", "column");
// Hauptlayout: Links (Bausteine) | Mitte (Canvas) | Rechts (Eigenschaften)
HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidth("100%");
mainLayout.setHeight("calc(100% - 60px)");
mainLayout.setSpacing(true);
mainLayout.getStyle().set("overflow", "hidden");
mainLayout.addClassName("invoice-generator-main");
VerticalLayout leftPanel = createTemplatesPanel();
leftPanel.setWidth("280px");
leftPanel.setHeightFull();
leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "280px").set("overflow", "auto");
VerticalLayout centerPanel = createCanvasPanel();
centerPanel.setWidth("60%");
centerPanel.setHeightFull();
centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0");
propertiesPanel = createPropertiesPanel();
propertiesPanel.setWidth("300px");
propertiesPanel.setHeightFull();
propertiesPanel.getStyle().set("flex-shrink", "0").set("min-width", "300px").set("overflow", "auto");
mainLayout.add(leftPanel, centerPanel, propertiesPanel);
mainLayout.expand(centerPanel);
add(mainLayout);
HorizontalLayout actionLayout = createActionButtons();
actionLayout.setHeight("60px");
actionLayout.getStyle().set("flex-shrink", "0");
add(actionLayout);
}
@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
+ "window.profileInvoiceVatRate = " + VAT_RATE.doubleValue() + ";"
+ "window.masterdataValues = JSON.parse($1);"
+ "window.profileInvoiceServiceData = JSON.parse($2);"
+ "setTimeout(function() { "
+ " if (window.initProfileInvoiceGenerator) { "
+ " window.initProfileInvoiceGenerator(); "
+ " setTimeout(function() { $0.$server.onCanvasReady(); }, 200); "
+ " } else { console.error('initProfileInvoiceGenerator not found'); } "
+ "}, 300);", getElement(), buildMasterdataJson(), buildCanvasPositionsJson());
}
/** Wird vom JavaScript gerufen, sobald das Canvas bereit ist. */
@ClientCallable
public void onCanvasReady() {
loadInitialTemplate();
}
/** Lädt beim Start das Standard-Template bzw. das erste vorhandene Template. */
private void loadInitialTemplate() {
List<String> names = invoiceTemplateService.templateNames();
templateSelect.setItems(names);
if (names.isEmpty()) {
return;
}
String initial = names.contains(InvoiceTemplateService.DEFAULT_TEMPLATE_NAME)
? InvoiceTemplateService.DEFAULT_TEMPLATE_NAME
: names.get(0);
templateSelect.setValue(initial);
loadTemplateIntoCanvas(initial);
}
/** Lädt das Template mit dem Namen auf die Zeichenfläche. */
private void loadTemplateIntoCanvas(String name) {
try {
Optional<String> templateData = invoiceTemplateService.loadTemplate(name);
if (templateData.isEmpty()) {
showNotification("Template \"" + name + "\" konnte nicht geladen werden.");
return;
}
currentTemplateName = name;
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " window.loadProfileTemplate(JSON.parse($0)); "
+ " } else { console.error('loadProfileTemplate or canvas not available'); } "
+ "}, 300);", templateData.get());
} catch (Exception ex) {
log.error("Rechnungstemplate konnte nicht geladen werden: {}", ex.getMessage(), ex);
}
}
/**
* Variablenwerte für Entwurf und Vorschau: Rechnungssteller ist fest die
* Assecutor Data Service GmbH, Empfänger und Positionen sind Beispieldaten.
* Beim späteren Einsatz des Templates werden sie durch echte Daten ersetzt.
*/
private Map<String, String> buildVariables() {
Map<String, String> variables = new LinkedHashMap<>();
variables.put("masterdata.company_name", "Assecutor Data Service GmbH");
variables.put("masterdata.street", "Gerhart-Hauptmann-Weg 14");
variables.put("masterdata.city", "21502 Geesthacht");
variables.put("masterdata.phone", "+49 40 18 123 771 0");
variables.put("masterdata.email", "mail@assecutor.de");
variables.put("masterdata.website", "www.assecutor.de");
variables.put("masterdata.sender_line",
"Assecutor Data Service GmbH · Gerhart-Hauptmann-Weg 14 · 21502 Geesthacht");
variables.put("masterdata.payment_terms", "Zahlbar innerhalb von 14 Tagen ohne Abzug.");
variables.put("masterdata.footer",
"Assecutor Data Service GmbH · Amtsgericht Lübeck HRB 8595 HL · USt-IdNr. DE261094748");
variables.put("masterdata.invoice_number", "RE-2026-0001");
variables.put("masterdata.invoice_date",
LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
variables.put("customer.company_name", "Musterfirma GmbH");
variables.put("customer.contact_name", "Max Mustermann");
variables.put("customer.street", "Musterstraße 1");
variables.put("customer.city", "12345 Musterstadt");
variables.put("customer.email", "kunde@beispiel.de");
variables.put("customer.phone", "0123 456789");
addPositions(variables);
return variables;
}
/** Beispielpositionen, solange noch keine Positionen erfasst wurden. */
private static final List<InvoiceMetadata.LineItem> SAMPLE_ITEMS = List.of(
new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE, new BigDecimal("450.00"),
new BigDecimal("19")),
new InvoiceMetadata.LineItem("Softwareentwicklung", BigDecimal.ONE, new BigDecimal("1200.00"),
new BigDecimal("19")),
new InvoiceMetadata.LineItem("Support-Pauschale", BigDecimal.ONE, new BigDecimal("150.00"),
new BigDecimal("19")));
/**
* Die in der E-Rechnungs-Maske erfassten Positionen; Beispieldaten als
* Fallback, solange noch nichts erfasst wurde.
*/
private List<InvoiceMetadata.LineItem> effectiveItems() {
List<InvoiceMetadata.LineItem> items = capturedInvoiceData.getItems();
return items.isEmpty() ? SAMPLE_ITEMS : items;
}
private void addPositions(Map<String, String> variables) {
TemplateVariables.addPositions(variables, effectiveItems());
}
/** JSON für die Canvas-Darstellung des services.list-Bausteins. */
private String buildCanvasPositionsJson() {
return TemplateVariables.canvasPositionsJson(effectiveItems());
}
private String buildMasterdataJson() {
try {
return new ObjectMapper().writeValueAsString(buildVariables());
} catch (Exception ex) {
log.error("Stammdaten konnten nicht serialisiert werden: {}", ex.getMessage(), ex);
return "{}";
}
}
// ==========================================
// Linkes Panel: Bausteine
// ==========================================
private VerticalLayout createTemplatesPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(true);
panel.setSpacing(true);
panel.setHeightFull();
panel.getStyle().set("overflow", "auto");
panel.addClassName("invoice-generator-panel");
Map<String, String> values = buildVariables();
// Bereich 1: Rechnungssteller (Assecutor Data Service GmbH)
Span issuerHeader = createSectionHeader("Rechnungssteller");
Div issuerCompany = createVariableTemplate("Firma", VaadinIcon.OFFICE,
"masterdata.company_name", values.get("masterdata.company_name"));
Div issuerStreet = createVariableTemplate("Straße", VaadinIcon.MAP_MARKER,
"masterdata.street", values.get("masterdata.street"));
Div issuerCity = createVariableTemplate("PLZ und Ort", VaadinIcon.BUILDING,
"masterdata.city", values.get("masterdata.city"));
Div issuerPhone = createVariableTemplate("Telefon", VaadinIcon.PHONE,
"masterdata.phone", values.get("masterdata.phone"));
Div issuerEmail = createVariableTemplate("E-Mail", VaadinIcon.ENVELOPE,
"masterdata.email", values.get("masterdata.email"));
Div issuerWebsite = createVariableTemplate("Webseite", VaadinIcon.GLOBE,
"masterdata.website", values.get("masterdata.website"));
Div issuerSenderLine = createVariableTemplate("Absenderzeile", VaadinIcon.ENVELOPE_OPEN_O,
"masterdata.sender_line", values.get("masterdata.sender_line"));
Div issuerPaymentTerms = createVariableTemplate("Zahlungsbedingungen", VaadinIcon.CREDIT_CARD,
"masterdata.payment_terms", values.get("masterdata.payment_terms"));
Div issuerFooter = createVariableTemplate("Fußzeile", VaadinIcon.FILE_TEXT,
"masterdata.footer", values.get("masterdata.footer"));
Div invoiceNumber = createVariableTemplate("Rechnungsnummer", VaadinIcon.HASH,
"masterdata.invoice_number", values.get("masterdata.invoice_number"));
Div invoiceDate = createVariableTemplate("Rechnungsdatum", VaadinIcon.CALENDAR_CLOCK,
"masterdata.invoice_date", values.get("masterdata.invoice_date"));
// Bereich 2: Positionen
Span servicesHeader = createSectionHeader("Positionen");
Div servicesListBlock = createServicesVariableTemplate("Positionsliste", VaadinIcon.LIST,
"services.list", "Position 1: 100,00 €\nPosition 2: 50,00 €");
// Bereich 3: Empfänger
Span recipientHeader = createSectionHeader("Rechnungsempfänger");
Div recipientCompany = createCustomerVariableTemplate("Firma", VaadinIcon.OFFICE,
"customer.company_name", values.get("customer.company_name"));
Div recipientName = createCustomerVariableTemplate("Ansprechpartner", VaadinIcon.USER,
"customer.contact_name", values.get("customer.contact_name"));
Div recipientStreet = createCustomerVariableTemplate("Straße", VaadinIcon.MAP_MARKER,
"customer.street", values.get("customer.street"));
Div recipientCity = createCustomerVariableTemplate("PLZ und Ort", VaadinIcon.BUILDING,
"customer.city", values.get("customer.city"));
Div recipientEmail = createCustomerVariableTemplate("E-Mail", VaadinIcon.ENVELOPE,
"customer.email", values.get("customer.email"));
// Bereich 4: Freie Elemente
Span freeHeader = createSectionHeader("Freie Elemente");
Div textBlock = createDraggableTemplate("Text", VaadinIcon.TEXT_LABEL, "text");
Div headerBlock = createDraggableTemplate("Überschrift", VaadinIcon.HEADER, "header");
Div dateBlock = createDraggableTemplate("Datum", VaadinIcon.CALENDAR, "date");
Div lineBlock = createDraggableTemplate("Linie", VaadinIcon.LINE_V, "line");
Div imageBlock = createDraggableTemplate("Bild", VaadinIcon.PICTURE, "image");
panel.add(issuerHeader, issuerCompany, issuerStreet, issuerCity, issuerPhone, issuerEmail, issuerWebsite,
issuerSenderLine, issuerPaymentTerms, issuerFooter, invoiceNumber, invoiceDate, servicesHeader,
servicesListBlock, recipientHeader, recipientCompany, recipientName, recipientStreet, recipientCity,
recipientEmail, freeHeader, textBlock, headerBlock, dateBlock, lineBlock, imageBlock);
return panel;
}
private Span createSectionHeader(String text) {
Span header = new Span(text);
header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-m)").set("margin-top",
"var(--lumo-space-s)");
return header;
}
/**
* Blauer Baustein: Stammdaten des Rechnungsstellers (masterdata.*).
*/
private Div createVariableTemplate(String label, VaadinIcon icon, String variable, String defaultText) {
Div template = new Div();
template.setText(label);
template.getStyle().set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0")
.set("background-color", "rgba(25, 118, 210, 0.1)").set("border", "1px solid rgba(25, 118, 210, 0.3)")
.set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex")
.set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none")
.set("font-size", "var(--lumo-font-size-s)").set("color", "#1976d2");
Icon templateIcon = icon.create();
templateIcon.setSize("var(--lumo-icon-size-s)");
template.getElement().insertChild(0, templateIcon.getElement());
template.getElement().setAttribute("draggable", "true");
template.getElement().setAttribute("data-template-type", "variable");
template.getElement().setAttribute("data-template-label", label);
template.getElement().setAttribute("data-variable", variable);
template.getElement().setAttribute("data-static-text", defaultText != null ? defaultText : "");
template.getElement()
.executeJs("this.addEventListener('dragstart', function(e) {"
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
+ " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));"
+ " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));"
+ " e.dataTransfer.setData('is-static', 'true');" + " this.style.opacity = '0.5';" + "});"
+ "this.addEventListener('dragend', function(e) {" + " this.style.opacity = '1';" + "});");
return template;
}
/**
* Grüner Baustein: Empfängerdaten (customer.*).
*/
private Div createCustomerVariableTemplate(String label, VaadinIcon icon, String variable, String defaultText) {
Div template = new Div();
template.setText(label);
template.getStyle().set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0")
.set("background-color", "rgba(46, 204, 113, 0.15)")
.set("border", "1px solid rgba(46, 204, 113, 0.4)").set("border-radius", "var(--lumo-border-radius-m)")
.set("cursor", "grab").set("display", "flex").set("align-items", "center")
.set("gap", "var(--lumo-space-s)").set("user-select", "none")
.set("font-size", "var(--lumo-font-size-s)").set("color", "#27ae60");
Icon templateIcon = icon.create();
templateIcon.setSize("var(--lumo-icon-size-s)");
template.getElement().insertChild(0, templateIcon.getElement());
template.getElement().setAttribute("draggable", "true");
template.getElement().setAttribute("data-template-type", "variable");
template.getElement().setAttribute("data-template-label", label);
template.getElement().setAttribute("data-variable", variable);
template.getElement().setAttribute("data-static-text", defaultText != null ? defaultText : "");
template.getElement().setAttribute("data-is-customer", "true");
template.getElement()
.executeJs("this.addEventListener('dragstart', function(e) {"
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
+ " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));"
+ " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));"
+ " e.dataTransfer.setData('is-static', 'true');"
+ " e.dataTransfer.setData('is-customer', this.getAttribute('data-is-customer'));"
+ " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {"
+ " this.style.opacity = '1';" + "});");
return template;
}
/**
* Gelber Baustein: Positionen (services.*).
*/
private Div createServicesVariableTemplate(String label, VaadinIcon icon, String variable, String defaultText) {
Div template = new Div();
template.setText(label);
template.getStyle().set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0")
.set("background-color", "rgba(255, 193, 7, 0.15)")
.set("border", "1px solid rgba(255, 193, 7, 0.4)").set("border-radius", "var(--lumo-border-radius-m)")
.set("cursor", "grab").set("display", "flex").set("align-items", "center")
.set("gap", "var(--lumo-space-s)").set("user-select", "none")
.set("font-size", "var(--lumo-font-size-s)").set("color", "#f57c00");
Icon templateIcon = icon.create();
templateIcon.setSize("var(--lumo-icon-size-s)");
template.getElement().insertChild(0, templateIcon.getElement());
template.getElement().setAttribute("draggable", "true");
template.getElement().setAttribute("data-template-type", "variable");
template.getElement().setAttribute("data-template-label", label);
template.getElement().setAttribute("data-variable", variable);
template.getElement().setAttribute("data-static-text", defaultText != null ? defaultText : "");
template.getElement().setAttribute("data-is-customer", "false");
template.getElement().setAttribute("data-is-services", "true");
template.getElement()
.executeJs("this.addEventListener('dragstart', function(e) {"
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
+ " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));"
+ " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));"
+ " e.dataTransfer.setData('is-static', 'true');"
+ " e.dataTransfer.setData('is-customer', this.getAttribute('data-is-customer'));"
+ " e.dataTransfer.setData('is-services', this.getAttribute('data-is-services'));"
+ " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {"
+ " this.style.opacity = '1';" + "});");
return template;
}
/**
* Neutraler Baustein: freie Elemente (Text, Überschrift, Datum, Linie, Bild).
*/
private Div createDraggableTemplate(String label, VaadinIcon icon, String type) {
Div template = new Div();
template.setText(label);
template.addClassName("invoice-generator-template");
Icon templateIcon = icon.create();
templateIcon.setSize("var(--lumo-icon-size-s)");
template.getElement().insertChild(0, templateIcon.getElement());
template.getElement().setAttribute("draggable", "true");
template.getElement().setAttribute("data-template-type", type);
template.getElement().setAttribute("data-template-label", label);
template.getElement()
.executeJs("this.addEventListener('dragstart', function(e) {"
+ " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));"
+ " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));"
+ " e.dataTransfer.setData('is-static', 'false');" + " this.style.opacity = '0.5';" + "});"
+ "this.addEventListener('dragend', function(e) {" + " this.style.opacity = '1';" + "});");
return template;
}
// ==========================================
// Mitte: Canvas
// ==========================================
private VerticalLayout createCanvasPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(false);
panel.setSpacing(false);
panel.setHeightFull();
// Container-ID muss zu profile-invoice-generator.js passen
Div canvasContainer = new Div();
canvasContainer.setId("invoice-canvas-container-profile");
canvasContainer.setWidth("100%");
canvasContainer.setHeight("100%");
canvasContainer.addClassName("invoice-generator-canvas");
panel.add(canvasContainer);
panel.expand(canvasContainer);
return panel;
}
// ==========================================
// Rechtes Panel: Eigenschaften
// ==========================================
private VerticalLayout createPropertiesPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(true);
panel.setSpacing(true);
panel.setHeightFull();
panel.getStyle().set("overflow", "auto");
panel.addClassName("invoice-generator-panel");
panel.add(propertiesHeader(), propertiesInfo());
return panel;
}
private Span propertiesHeader() {
Span header = new Span("Eigenschaften");
header.addClassName("invoice-generator-panel-title");
return header;
}
private Div propertiesInfo() {
Div info = new Div();
info.setText("Element auf der Zeichenfläche auswählen, um seine Eigenschaften zu bearbeiten.");
info.addClassName("invoice-generator-info");
return info;
}
@ClientCallable
public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y,
Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable) {
getUI().ifPresent(ui -> ui.access(() -> {
propertiesPanel.removeAll();
String typeDisplay = "Typ: " + elementType;
if (variable != null && !variable.isEmpty()) {
typeDisplay += " (Variable)";
}
Span typeLabel = new Span(typeDisplay);
typeLabel.addClassName("invoice-generator-info");
propertiesPanel.add(propertiesHeader(), typeLabel);
// Variable anzeigen wenn vorhanden
if (variable != null && !variable.isEmpty()) {
TextField variableField = new TextField("Variable");
variableField.setValue(variable);
variableField.setReadOnly(true);
variableField.setWidthFull();
variableField.setHelperText(getVariableDescription(variable));
propertiesPanel.add(variableField);
}
// Für Bildelemente: Upload anzeigen
if ("image".equals(elementType)) {
MemoryBuffer buffer = new MemoryBuffer();
Upload upload = new Upload(buffer);
upload.setAcceptedFileTypes("image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp");
upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB
upload.setDropLabel(new Span("Bild hierher ziehen oder klicken"));
upload.setWidthFull();
upload.addSucceededListener(event -> {
try {
byte[] bytes = buffer.getInputStream().readAllBytes();
String base64 = Base64.getEncoder().encodeToString(bytes);
String dataUrl = "data:" + event.getMIMEType() + ";base64," + base64;
getElement()
.executeJs("if (window.updateProfileElementImage) { window.updateProfileElementImage('"
+ elementId + "', $0); }", dataUrl);
showNotification("Bild übernommen.");
} catch (Exception ex) {
showNotification("Bild konnte nicht geladen werden: " + ex.getMessage());
}
});
upload.addFileRejectedListener(event ->
showNotification("Datei abgelehnt: " + event.getErrorMessage()));
propertiesPanel.add(upload);
}
// Textfeld (nur für Text-Elemente)
if (!"line".equals(elementType) && !"image".equals(elementType)) {
TextField textField = new TextField("Text");
textField.setValue(text != null ? text : "");
textField.setWidthFull();
if (Boolean.TRUE.equals(isStatic)) {
textField.setReadOnly(true);
textField.setHelperText("Wert wird aus den Stammdaten befüllt");
} else {
textField.addValueChangeListener(e -> getElement()
.executeJs("if (window.updateProfileElementText) { window.updateProfileElementText('"
+ elementId + "', $0); }", e.getValue()));
}
propertiesPanel.add(textField);
}
// X Position
TextField xField = new TextField("X Position");
xField.setValue(x != null ? String.valueOf(Math.round(x)) : "0");
xField.setWidthFull();
xField.addValueChangeListener(e -> {
try {
double newX = Double.parseDouble(e.getValue());
getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', $0, null); }",
newX);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(xField);
// Y Position
TextField yField = new TextField("Y Position");
yField.setValue(y != null ? String.valueOf(Math.round(y)) : "0");
yField.setWidthFull();
yField.addValueChangeListener(e -> {
try {
double newY = Double.parseDouble(e.getValue());
getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', null, $0); }",
newY);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(yField);
// Schriftgröße und Farbe (nur für Text-Elemente)
if (!"line".equals(elementType) && !"image".equals(elementType)) {
TextField fontSizeField = new TextField("Schriftgröße");
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
fontSizeField.setWidthFull();
fontSizeField.addValueChangeListener(e -> {
try {
int newSize = Integer.parseInt(e.getValue());
getElement().executeJs(
"if (window.updateProfileElementFontSize) { window.updateProfileElementFontSize('"
+ elementId + "', $0); }",
newSize);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(fontSizeField);
// Farbe
Div colorContainer = new Div();
colorContainer.getStyle().set("display", "flex").set("align-items", "center")
.set("gap", "var(--lumo-space-s)").set("margin-top", "var(--lumo-space-s)");
Span colorLabel = new Span("Farbe");
colorLabel.getStyle().set("font-size", "var(--lumo-font-size-s)");
Input colorPicker = new Input();
colorPicker.setType("color");
colorPicker.setValue(color != null ? color : "#333333");
colorPicker.getStyle().set("width", "50px").set("height", "32px").set("padding", "0")
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "pointer");
TextField colorHexField = new TextField();
colorHexField.setValue(color != null ? color : "#333333");
colorHexField.setWidth("100px");
colorHexField.getStyle().set("margin", "0");
colorPicker.addValueChangeListener(e -> {
String newColor = e.getValue();
colorHexField.setValue(newColor);
getElement().executeJs("if (window.updateProfileElementColor) { window.updateProfileElementColor('"
+ elementId + "', $0); }", newColor);
});
colorHexField.addValueChangeListener(e -> {
String newColor = e.getValue();
if (newColor.matches("^#[0-9A-Fa-f]{6}$")) {
colorPicker.setValue(newColor);
getElement()
.executeJs("if (window.updateProfileElementColor) { window.updateProfileElementColor('"
+ elementId + "', $0); }", newColor);
}
});
colorContainer.add(colorLabel, colorPicker, colorHexField);
propertiesPanel.add(colorContainer);
}
// Löschen-Button
Button deleteButton = new Button("Löschen", new Icon(VaadinIcon.TRASH));
deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
deleteButton.setWidthFull();
deleteButton.addClickListener(e -> {
getElement().executeJs(
"if (window.deleteProfileElement) { window.deleteProfileElement('" + elementId + "'); }");
resetPropertiesPanel();
});
propertiesPanel.add(deleteButton);
}));
}
@ClientCallable
public void resetPropertiesPanel() {
getUI().ifPresent(ui -> ui.access(() -> {
if (propertiesPanel == null) {
return;
}
propertiesPanel.removeAll();
propertiesPanel.add(propertiesHeader(), propertiesInfo());
}));
}
private String getVariableDescription(String variable) {
return switch (variable) {
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
case "masterdata.street" -> "Straße und Hausnummer des Rechnungsstellers";
case "masterdata.city" -> "PLZ und Ort des Rechnungsstellers";
case "masterdata.phone" -> "Telefonnummer des Rechnungsstellers";
case "masterdata.email" -> "E-Mail-Adresse des Rechnungsstellers";
case "masterdata.website" -> "Webseite des Rechnungsstellers";
case "masterdata.sender_line" -> "Absenderzeile über dem Adressfeld";
case "masterdata.payment_terms" -> "Zahlungsbedingungen";
case "masterdata.footer" -> "Fußzeile mit Registerdaten und USt-IdNr.";
case "masterdata.invoice_number" -> "Rechnungsnummer";
case "masterdata.invoice_date" -> "Rechnungsdatum";
case "customer.company_name" -> "Firmenname des Rechnungsempfängers";
case "customer.contact_name" -> "Ansprechpartner des Rechnungsempfängers";
case "customer.street" -> "Straße des Rechnungsempfängers";
case "customer.city" -> "PLZ und Ort des Rechnungsempfängers";
case "customer.email" -> "E-Mail des Rechnungsempfängers";
case "services.list" -> "Liste aller Rechnungspositionen";
case "services.net_total" -> "Nettosumme aller Positionen";
case "services.gross_total" -> "Bruttosumme aller Positionen";
default -> "Variable: " + variable;
};
}
// ==========================================
// Aktionen
// ==========================================
private HorizontalLayout createActionButtons() {
HorizontalLayout layout = new HorizontalLayout();
layout.setWidth("100%");
layout.setJustifyContentMode(JustifyContentMode.END);
layout.setPadding(false);
layout.setSpacing(true);
layout.setAlignItems(Alignment.CENTER);
layout.addClassName("invoice-generator-actionbar");
templateSelect = new ComboBox<>();
templateSelect.setPlaceholder("Template auswählen");
templateSelect.setWidth("220px");
templateSelect.addValueChangeListener(event -> {
// Nur auf echte Benutzerauswahl reagieren; programmatische
// setValue-Aufrufe (Initial-Load, nach dem Speichern) laden nicht erneut.
if (event.isFromClient() && event.getValue() != null) {
loadTemplateIntoCanvas(event.getValue());
}
});
Button undoButton = new Button("Rückgängig", new Icon(VaadinIcon.ARROW_BACKWARD));
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
undoButton.addClickListener(
e -> getElement().executeJs("if (window.undoProfileCanvas) { window.undoProfileCanvas(); }"));
Button previewButton = new Button("Vorschau", new Icon(VaadinIcon.EYE));
previewButton.addThemeVariants(ButtonVariant.LUMO_CONTRAST);
previewButton.addClickListener(e -> generatePreviewPdf());
Button saveTemplateButton = new Button("Template speichern", new Icon(VaadinIcon.CHECK));
saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
saveTemplateButton.addClickListener(e -> saveTemplate());
layout.add(templateSelect, undoButton, previewButton, saveTemplateButton);
return layout;
}
private void saveTemplate() {
getElement().executeJs(
"return window.getProfileCanvasData ? JSON.stringify(window.getProfileCanvasData()) : null;")
.then(String.class, templateData -> {
if (templateData == null) {
showNotification("Zeichenfläche ist nicht bereit.");
return;
}
openSaveDialog(templateData);
});
}
/** Fragt den Template-Namen ab; bei vorhandenem Namen wird das Überschreiben bestätigt. */
private void openSaveDialog(String templateData) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Template speichern");
TextField nameField = new TextField("Name des Templates");
nameField.setValue(currentTemplateName != null ? currentTemplateName : "");
nameField.setWidth("300px");
dialog.add(nameField);
Button cancelButton = new Button("Abbrechen", e -> dialog.close());
Button saveButton = new Button("Speichern", e -> {
String name = nameField.getValue() == null ? "" : nameField.getValue().trim();
if (name.isEmpty()) {
nameField.setErrorMessage("Bitte einen Namen angeben.");
nameField.setInvalid(true);
return;
}
dialog.close();
if (invoiceTemplateService.templateExists(name)) {
confirmOverwrite(name, templateData);
} else {
doSaveTemplate(name, templateData);
}
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelButton, saveButton);
dialog.open();
nameField.focus();
}
/** Rückfrage, ob das bereits vorhandene Template gleichen Namens überschrieben werden soll. */
private void confirmOverwrite(String name, String templateData) {
ConfirmDialog confirm = new ConfirmDialog();
confirm.setHeader("Template überschreiben?");
confirm.setText("Es existiert bereits ein Template mit dem Namen \"" + name
+ "\". Soll es überschrieben werden?");
confirm.setConfirmText("Überschreiben");
confirm.setCancelable(true);
confirm.setCancelText("Abbrechen");
confirm.addConfirmListener(e -> doSaveTemplate(name, templateData));
confirm.addCancelListener(e -> openSaveDialog(templateData));
confirm.open();
}
private void doSaveTemplate(String name, String templateData) {
try {
invoiceTemplateService.saveTemplate(name, templateData);
currentTemplateName = name;
templateSelect.setItems(invoiceTemplateService.templateNames());
templateSelect.setValue(name);
showNotification("Template \"" + name + "\" gespeichert.");
} catch (Exception ex) {
showNotification("Template konnte nicht gespeichert werden: " + ex.getMessage());
}
}
private void generatePreviewPdf() {
getElement().executeJs(
"return window.getProfileCanvasData ? JSON.stringify(window.getProfileCanvasData()) : null;")
.then(String.class, templateData -> {
if (templateData == null) {
showNotification("Zeichenfläche ist nicht bereit.");
return;
}
try {
byte[] pdfBytes = templatePdfService.generatePdf(templateData, buildVariables(), VAT_RATE);
showPdfInDialog(pdfBytes);
} catch (Exception ex) {
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());
}
});
}
private void showPdfInDialog(byte[] pdfBytes) {
Dialog pdfDialog = new Dialog();
pdfDialog.setHeaderTitle("PDF-Vorschau");
pdfDialog.setWidth("90vw");
pdfDialog.setHeight("90vh");
Div pdfContainer = new Div();
pdfContainer.setWidth("100%");
pdfContainer.setHeight("100%");
pdfContainer.addClassName("invoice-generator-pdf");
String base64Pdf = Base64.getEncoder().encodeToString(pdfBytes);
String dataUrl = "data:application/pdf;base64," + base64Pdf;
IFrame pdfFrame = new IFrame();
pdfFrame.setWidth("100%");
pdfFrame.setHeight("100%");
pdfFrame.getElement().setAttribute("src", dataUrl);
pdfFrame.addClassName("invoice-generator-frame");
pdfContainer.add(pdfFrame);
Button closeButton = new Button("Schließen", e -> pdfDialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button downloadButton = new Button("Herunterladen", e -> getElement()
.executeJs("const link = document.createElement('a');"
+ "link.href = $0;"
+ "link.download = 'rechnungstemplate-vorschau.pdf';"
+ "link.click();", dataUrl));
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
pdfDialog.add(pdfContainer);
pdfDialog.getFooter().add(downloadButton, closeButton);
pdfDialog.open();
}
private void showNotification(String message) {
Notification.show(message, 3000, Notification.Position.BOTTOM_CENTER);
}
}

View File

@@ -0,0 +1,12 @@
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);
}
}

View File

@@ -0,0 +1,8 @@
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) {
}

View File

@@ -0,0 +1,242 @@
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());
}
}

View File

@@ -0,0 +1,38 @@
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) {
}
}

View File

@@ -0,0 +1,17 @@
spring.application.name=pdf-tool
server.port=8080
spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=30MB
vaadin.launch-browser=false
# Zugangsdaten aus der .env-Datei im Projektverzeichnis laden (falls vorhanden)
spring.config.import=optional:file:.env[.properties]
# MongoDB: Ablage der Rechnungstemplates. Die Verbindung ist zwingend --
# ohne erreichbaren Server bricht der Start ab (MongoConnectionCheck, mit
# klarer Fehlermeldung; deshalb kein auto-index-creation).
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017/pdf-tool}
spring.data.mongodb.connect-timeout=10000
spring.data.mongodb.socket-timeout=30000

View File

@@ -0,0 +1,48 @@
package de.assecutor.pdftool.invoice;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
class VatIdValidatorTest {
@ParameterizedTest
@ValueSource(strings = {
"DE261094748", // Assecutor Data Service GmbH
"DE136695976", // gültige Prüfziffer
"de 261 094 748", // Leerzeichen und Kleinschreibung werden normalisiert
"ATU12345678",
"NL123456789B01",
"FRXX123456789"
})
void accepts_valid_vat_ids(String vatId) {
assertThat(VatIdValidator.validate(vatId)).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {
"DE261094749", // falsche Prüfziffer
"DE12345678", // zu kurz
"DE1234567890", // zu lang
"XX123456789", // unbekanntes Länderpräfix
"123456789", // Länderpräfix fehlt
"ATU1234567", // falsches Format für AT
"DE" // nur Präfix
})
void rejects_invalid_vat_ids(String vatId) {
assertThat(VatIdValidator.validate(vatId)).isPresent();
}
@Test
void rejects_blank_values() {
assertThat(VatIdValidator.validate(null)).isPresent();
assertThat(VatIdValidator.validate(" ")).isPresent();
}
@Test
void normalizes_separators_and_case() {
assertThat(VatIdValidator.normalize("de 261-094.748")).isEqualTo("DE261094748");
}
}

View File

@@ -0,0 +1,63 @@
package de.assecutor.pdftool.template;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TemplatePdfServiceTest {
private final TemplatePdfService service = new TemplatePdfService();
@Test
void rendersCanvasTemplateWithVariablesAndPositionsToPdf() {
String template = """
{"elements": [
{"id": "e1", "type": "text", "variable": "masterdata.company_name",
"xPercent": 10, "yPercent": 5, "widthPercent": 40, "heightPercent": 3},
{"id": "e2", "type": "text", "text": "Freier Text",
"xPercent": 10, "yPercent": 10, "widthPercent": 40, "heightPercent": 3},
{"id": "e3", "type": "line", "xPercent": 10, "yPercent": 15, "widthPercent": 80, "heightPercent": 0},
{"id": "e4", "type": "text", "variable": "services.list",
"xPercent": 10, "yPercent": 20, "widthPercent": 80, "heightPercent": 20}
]}""";
Map<String, String> variables = Map.of(
"masterdata.company_name", "Assecutor Data Service GmbH",
"services.json",
"[{\"name\":\"Beratung\",\"netAmount\":\"100,00\",\"vat\":\"19%\"},"
+ "{\"name\":\"Fachbuch\",\"netAmount\":\"50,00\",\"vat\":\"7%\"}]",
"invoice.net_total", "150,00 €",
"invoice.vat_total", "22,50 €",
"invoice.gross_total", "172,50 €",
"invoice.vat_rate", "");
byte[] pdf = service.generatePdf(template, variables, new BigDecimal("0.19"));
assertTrue(pdf.length > 500, "PDF sollte Inhalt haben");
assertArrayEquals("%PDF-".getBytes(StandardCharsets.US_ASCII),
java.util.Arrays.copyOfRange(pdf, 0, 5), "Ergebnis muss ein PDF sein");
}
@Test
void legacyPixelCoordinatesAreConverted() {
String template = """
{"elements": [
{"id": "e1", "type": "text", "text": "Legacy", "x": 100, "y": 100, "width": 200, "height": 30}
]}""";
byte[] pdf = service.generatePdf(template, Map.of(), null);
assertTrue(pdf.length > 500);
}
@Test
void invalidJsonThrowsTemplateRenderException() {
assertThrows(TemplateRenderException.class,
() -> service.generatePdf("kein json", Map.of(), null));
}
}

View File

@@ -0,0 +1,60 @@
package de.assecutor.pdftool.template;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TemplateVariablesTest {
private static InvoiceMetadata.LineItem item(String description, String quantity, String unitPrice, String vat) {
return new InvoiceMetadata.LineItem(description, new BigDecimal(quantity), new BigDecimal(unitPrice),
new BigDecimal(vat));
}
@Test
void addPositionsComputesTotalsFromQuantityAndUnitPrice() {
Map<String, String> variables = new HashMap<>();
TemplateVariables.addPositions(variables, List.of(
item("Beratung", "2", "100.00", "19"),
item("Fachbuch", "1", "50.00", "19")));
assertEquals("250,00 €", variables.get("invoice.net_total"));
assertEquals("47,50 €", variables.get("invoice.vat_total"));
assertEquals("297,50 €", variables.get("invoice.gross_total"));
assertEquals("19%", variables.get("invoice.vat_rate"));
assertEquals("2", variables.get("services.count"));
assertTrue(variables.get("services.json").contains("\"netAmount\":\"200,00\""),
"Nettobetrag der Position muss Menge x Einzelpreis sein");
}
@Test
void mixedVatRatesYieldEmptyUniformLabelAndPerRowVat() {
Map<String, String> variables = new HashMap<>();
TemplateVariables.addPositions(variables, List.of(
item("Beratung", "1", "100.00", "19"),
item("Fachbuch", "1", "50.00", "7")));
assertEquals("", variables.get("invoice.vat_rate"));
assertEquals("22,50 €", variables.get("invoice.vat_total"));
assertTrue(variables.get("services.json").contains("\"vat\":\"19%\""));
assertTrue(variables.get("services.json").contains("\"vat\":\"7%\""));
}
@Test
void canvasPositionsJsonContainsRowsAndTotals() {
String json = TemplateVariables.canvasPositionsJson(List.of(item("Beratung", "1", "100.00", "19")));
assertTrue(json.contains("\"rows\""));
assertTrue(json.contains("\"netTotal\":\"100,00 €\""));
assertTrue(json.contains("\"grossTotal\":\"119,00 €\""));
}
}

View File

@@ -0,0 +1,133 @@
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;
}
}