Initialer Stand des PDF Tools

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

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