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:
5
.env.example
Normal file
5
.env.example
Normal 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
13
.gitignore
vendored
Normal 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
32
.vscode/launch.json
vendored
Normal 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
137
README.md
Normal 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
127
pom.xml
Normal 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>
|
||||
32
src/main/bundles/README.md
Normal file
32
src/main/bundles/README.md
Normal 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
BIN
src/main/bundles/dev.bundle
Normal file
Binary file not shown.
23
src/main/frontend/index.html
Normal file
23
src/main/frontend/index.html
Normal 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>
|
||||
1403
src/main/frontend/invoice-generator/profile-invoice-generator.js
Normal file
1403
src/main/frontend/invoice-generator/profile-invoice-generator.js
Normal file
File diff suppressed because it is too large
Load Diff
77
src/main/frontend/styles/invoice-generator.css
Normal file
77
src/main/frontend/styles/invoice-generator.css
Normal 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;
|
||||
}
|
||||
12
src/main/java/de/assecutor/pdftool/PdfToolApplication.java
Normal file
12
src/main/java/de/assecutor/pdftool/PdfToolApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
124
src/main/java/de/assecutor/pdftool/address/Address.java
Normal file
124
src/main/java/de/assecutor/pdftool/address/Address.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
18
src/main/java/de/assecutor/pdftool/address/AddressType.java
Normal file
18
src/main/java/de/assecutor/pdftool/address/AddressType.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
35
src/main/java/de/assecutor/pdftool/invoice/InvoiceDraft.java
Normal file
35
src/main/java/de/assecutor/pdftool/invoice/InvoiceDraft.java
Normal 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
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
45
src/main/java/de/assecutor/pdftool/invoice/ValidVatId.java
Normal file
45
src/main/java/de/assecutor/pdftool/invoice/ValidVatId.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/main/java/de/assecutor/pdftool/invoice/VatIdValidator.java
Normal file
106
src/main/java/de/assecutor/pdftool/invoice/VatIdValidator.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) : "";
|
||||
}
|
||||
}
|
||||
248
src/main/java/de/assecutor/pdftool/ui/AddressBookView.java
Normal file
248
src/main/java/de/assecutor/pdftool/ui/AddressBookView.java
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/main/java/de/assecutor/pdftool/ui/MainLayout.java
Normal file
59
src/main/java/de/assecutor/pdftool/ui/MainLayout.java
Normal 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() : "";
|
||||
}
|
||||
}
|
||||
847
src/main/java/de/assecutor/pdftool/ui/MainView.java
Normal file
847
src/main/java/de/assecutor/pdftool/ui/MainView.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
900
src/main/java/de/assecutor/pdftool/ui/TemplateGeneratorView.java
Normal file
900
src/main/java/de/assecutor/pdftool/ui/TemplateGeneratorView.java
Normal 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 & 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
242
src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java
Normal file
242
src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
17
src/main/resources/application.properties
Normal file
17
src/main/resources/application.properties
Normal 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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 €\""));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user