commit 960431914cfe777525f1370f32f6f0e552c5dd8f Author: Sven Carstensen Date: Thu Jul 9 16:02:54 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f0c334 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Vorlage fuer die .env-Datei: kopieren nach .env und Werte eintragen. +# cp .env.example .env + +# MongoDB-Verbindung fuer die Rechnungstemplates +MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a738566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Zugangsdaten +.env + +# Build +target/ + +# Vaadin +src/main/frontend/generated/ +node_modules/ +vite.config.ts.timestamp-* + +# IDE / OS +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c093f08 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "PDF Tool (Dev, Formular vorbelegt)", + "request": "launch", + "mainClass": "de.assecutor.pdftool.PdfToolApplication", + "projectName": "pdf-tool", + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "vmArgs": "-Dspring.profiles.active=dev" + }, + { + "type": "java", + "name": "PDF Tool (Port 8081)", + "request": "launch", + "mainClass": "de.assecutor.pdftool.PdfToolApplication", + "projectName": "pdf-tool", + "cwd": "${workspaceFolder}", + "console": "internalConsole", + "vmArgs": "-Dserver.port=8081" + }, + { + "type": "java", + "name": "Attach an laufende JVM (5005)", + "request": "attach", + "hostName": "localhost", + "port": 5005 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4419978 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# PDF Tool — ZUGFeRD E-Rechnungs-Konverter + +Spring Boot + Vaadin Flow Anwendung, die ein herkömmliches B2B-Rechnungs-PDF in eine +**rechtskonforme ZUGFeRD-E-Rechnung** umwandelt (PDF/A-3 mit eingebettetem EN16931-XML, +Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als +**ZIP-Datei** bereitstellt. + +Verwendet wird [Mustangproject](https://www.mustangproject.org/) (Apache-2.0), die +Open-Source-Referenzimplementierung von ZUGFeRD, inklusive des Mustang-Validators +(XSD + EN16931-Schematron für das XML, veraPDF für die PDF/A-3-Konformität). + +> **Hinweis zur Rechtskonformität:** ZUGFeRD "signiert" nicht kryptografisch. Rechtskonform +> im Sinne der deutschen B2B-E-Rechnungspflicht (§ 14 UStG, seit 01.01.2025) ist eine Rechnung +> im strukturierten EN16931-Format — genau das erzeugt dieses Tool. Eine qualifizierte +> elektronische Signatur ist seit 2011 nicht mehr erforderlich. + +## Voraussetzungen + +- Java 21+ +- Maven 3.9+ + +## Starten + +```bash +mvn spring-boot:run +``` + +Die Weboberfläche ist dann unter erreichbar: +PDF hochladen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken, ZIP herunterladen. + +Produktions-Build: + +```bash +mvn clean package -Pproduction +java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar +``` + +## REST-API + +### E-Rechnung erzeugen + +`POST /api/invoices/sign` (multipart/form-data) mit zwei Parts: + +| Part | Inhalt | +|------------|-------------------------------------------| +| `file` | das Rechnungs-PDF | +| `metadata` | Rechnungsdaten als JSON (siehe unten) | + +Antwort: `application/zip` mit `-zugferd.pdf`, `factur-x.xml` und +`validation-report.xml` (Mustang-Prüfbericht). Der Header `X-Zugferd-Valid: true|false` +meldet das Validierungsergebnis. Bei Fehlern: HTTP 400 mit `{"error": "..."}`. + +> Häufigste Ursache für `X-Zugferd-Valid: false`: Das Quell-PDF bettet seine +> Schriften nicht ein. Das kann die Konvertierung nicht reparieren — das PDF muss +> dann mit eingebetteten Schriften neu erzeugt werden (Standard bei den meisten +> ERP-/Reporting-Systemen konfigurierbar). + +### Bestehende E-Rechnung prüfen + +`POST /api/invoices/validate` (multipart/form-data, Part `file` = ZUGFeRD-PDF oder +Factur-X-XML). Antwort: der Mustang-Prüfbericht als XML — HTTP 200 bei gültiger, +HTTP 422 bei ungültiger Rechnung, ebenfalls mit `X-Zugferd-Valid`-Header. + +```bash +curl -X POST http://localhost:8080/api/invoices/validate \ + -F "file=@rechnung-zugferd.pdf" +``` + +### Beispiel + +```bash +curl -X POST http://localhost:8080/api/invoices/sign \ + -F "file=@rechnung.pdf" \ + -F "metadata=@metadata.json;type=application/json" \ + -o rechnung-zugferd.zip +``` + +`metadata.json`: + +```json +{ + "invoiceNumber": "RE-2026-0815", + "issueDate": "2026-07-08", + "deliveryDate": "2026-07-01", + "dueDate": "2026-07-22", + "currency": "EUR", + "paymentTerms": "Zahlbar innerhalb von 14 Tagen ohne Abzug.", + "sender": { + "name": "Assecutor GmbH", + "street": "Musterstrasse 1", + "zip": "20095", + "city": "Hamburg", + "countryCode": "DE", + "vatId": "DE123456789" + }, + "recipient": { + "name": "Kunde AG", + "street": "Beispielweg 2", + "zip": "10115", + "city": "Berlin", + "countryCode": "DE" + }, + "items": [ + { + "description": "Beratungsleistung", + "quantity": 1, + "unitPriceNet": 1500.00, + "vatPercent": 19 + } + ] +} +``` + +Pflichtfelder: `invoiceNumber`, `issueDate`, `sender` (inkl. Adresse), `recipient`, +mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum zurück, +`dueDate` auf Rechnungsdatum + 14 Tage, `currency` auf `EUR`. + +## Architektur + +- [ZugferdService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java) — Kernlogik: + PDF → PDF/A-3 + EN16931-XML (Mustangproject), Validierung, Verpackung als ZIP. + Nicht-PDF/A-Eingaben (der Normalfall) werden tolerant konvertiert. +- [ZugferdValidationService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java) — Mustang-Validator + (XSD + Schematron + veraPDF), liefert Status und XML-Prüfbericht +- [InvoiceSigningController](src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java) — REST-Endpunkte `/sign` und `/validate` +- [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche +- [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten + +## Tests + +```bash +mvn test +``` + +[ZugferdServiceTest](src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java) erzeugt ein +Test-PDF, konvertiert es und prüft, dass das ZIP ein PDF/A-3 mit eingebettetem +`factur-x.xml` (EN16931-Profil) enthält. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..97c0d36 --- /dev/null +++ b/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.3 + + + + de.assecutor + pdf-tool + 1.0.0-SNAPSHOT + pdf-tool + ZUGFeRD E-Rechnungs-Konverter (PDF zu PDF/A-3 mit eingebettetem EN16931-XML) + + + 21 + 24.7.4 + 2.24.0 + + + + + + com.vaadin + vaadin-bom + ${vaadin.version} + pom + import + + + + + + + com.vaadin + vaadin-spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + org.mustangproject + library + ${mustang.version} + + + + + com.itextpdf + html2pdf + 5.0.5 + + + org.mustangproject + validator + ${mustang.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + prepare-frontend + + + + + + + + + + production + + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + build-frontend + + compile + + + + + + + + diff --git a/src/main/bundles/README.md b/src/main/bundles/README.md new file mode 100644 index 0000000..c9737d1 --- /dev/null +++ b/src/main/bundles/README.md @@ -0,0 +1,32 @@ +This directory is automatically generated by Vaadin and contains the pre-compiled +frontend files/resources for your project (frontend development bundle). + +It should be added to Version Control System and committed, so that other developers +do not have to compile it again. + +Frontend development bundle is automatically updated when needed: +- an npm/pnpm package is added with @NpmPackage or directly into package.json +- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript +- Vaadin add-on with front-end customizations is added +- Custom theme imports/assets added into 'theme.json' file +- Exported web component is added. + +If your project development needs a hot deployment of the frontend changes, +you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions): +- set `vaadin.frontend.hotdeploy=true` in `application.properties` +- configure `vaadin-maven-plugin`: +``` + + true + +``` +- configure `jetty-maven-plugin`: +``` + + + true + + +``` + +Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle). \ No newline at end of file diff --git a/src/main/bundles/dev.bundle b/src/main/bundles/dev.bundle new file mode 100644 index 0000000..1a86cbf Binary files /dev/null and b/src/main/bundles/dev.bundle differ diff --git a/src/main/frontend/index.html b/src/main/frontend/index.html new file mode 100644 index 0000000..eb0c53b --- /dev/null +++ b/src/main/frontend/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + +
+ + diff --git a/src/main/frontend/invoice-generator/profile-invoice-generator.js b/src/main/frontend/invoice-generator/profile-invoice-generator.js new file mode 100644 index 0000000..b4611e8 --- /dev/null +++ b/src/main/frontend/invoice-generator/profile-invoice-generator.js @@ -0,0 +1,1403 @@ +// Profile Invoice Generator - Initializes canvas on the edit-profile page +// Global state to persist between tab switches +window.profileInvoiceState = window.profileInvoiceState || { + elements: [], + selectedElement: null, + elementCounter: 0, + initialized: false +}; + +window.initProfileInvoiceGenerator = function() { + var containerId = 'invoice-canvas-container-profile'; + var container = document.getElementById(containerId); + + if (!container) { + console.error('Canvas container not found:', containerId); + return; + } + + // Always clear and recreate canvas to ensure clean state + container.innerHTML = ''; + window.profileInvoiceState.initialized = false; + + // Get container dimensions + var rect = container.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + console.error('Container has no size'); + return; + } + + // Create canvas element + var canvas = document.createElement('canvas'); + canvas.style.display = 'block'; + canvas.width = rect.width; + canvas.height = rect.height; + container.appendChild(canvas); + + var ctx = canvas.getContext('2d'); + + // Restore state from global or initialize + var elements = window.profileInvoiceState.elements; + var selectedElement = window.profileInvoiceState.selectedElement; + var elementCounter = window.profileInvoiceState.elementCounter; + + // Undo history: snapshots of the elements array, max 10 steps back. + // Reset on every init so a canvas never restores states of the other + // generator page (profile vs. admin system template). + var undoStack = []; + var MAX_UNDO_STEPS = 10; + + function pushUndoState() { + try { + var snapshot = JSON.stringify(elements); + if (undoStack.length && undoStack[undoStack.length - 1] === snapshot) return; + undoStack.push(snapshot); + if (undoStack.length > MAX_UNDO_STEPS) undoStack.shift(); + } catch (err) { + console.error('Could not record undo state:', err); + } + } + + window.undoProfileCanvas = function() { + if (!undoStack.length) return false; + var snapshot = JSON.parse(undoStack.pop()); + // Keep the same array reference (shared with profileInvoiceState) + elements.length = 0; + snapshot.forEach(function(el) { elements.push(el); }); + selectedElement = null; + saveState(); + notifyElementDeselected(); + draw(); + return true; + }; + var isDragging = false; + var dragStart = { x: 0, y: 0 }; + var elementStart = { x: 0, y: 0 }; + var gridSize = 5; + + // Image cache to prevent flickering during resize + var imageCache = {}; + + // Page dimensions + var padding = 10; + var pageX, pageY, pageWidth, pageHeight; + var zoomFactor = 1; + var basePageWidth = 595; // A4 width in pixels at 96 DPI + var basePageHeight = 842; // A4 height in pixels at 96 DPI + + function updatePageDimensions() { + var w = canvas.width; + var h = canvas.height; + var availableWidth = w - padding * 2; + var availableHeight = h - padding * 2; + + // Calculate zoom factor based on available space + var zoomX = availableWidth / basePageWidth; + var zoomY = availableHeight / basePageHeight; + zoomFactor = Math.min(zoomX, zoomY); + + pageWidth = basePageWidth * zoomFactor; + pageHeight = basePageHeight * zoomFactor; + pageX = (w - pageWidth) / 2; + pageY = (h - pageHeight) / 2; + } + + function snapToGrid(value) { + return Math.round(value / gridSize) * gridSize; + } + + // Notify Java about element selection + function notifyElementSelected(el) { + if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) { + window.invoiceGeneratorViewProfile.$server.updatePropertiesPanel( + el.id, + el.type, + el.text || '', + el.x, + el.y, + el.fontSize || 14, + el.color || '#333333', + el.width || 100, + el.height || 30, + el.isStatic || false, + el.variable || null + ); + } + } + + function notifyElementDeselected() { + if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) { + window.invoiceGeneratorViewProfile.$server.resetPropertiesPanel(); + } + } + + // Draw function + function draw() { + console.log('draw() called, elements:', elements.length); + var w = canvas.width; + var h = canvas.height; + console.log('Canvas size:', w, 'x', h); + + // Clear background + ctx.fillStyle = '#e8e8e8'; + ctx.fillRect(0, 0, w, h); + + updatePageDimensions(); + + // Page shadow + ctx.fillStyle = 'rgba(0,0,0,0.2)'; + ctx.fillRect(pageX + 4, pageY + pageHeight, pageWidth, 4); + ctx.fillRect(pageX + pageWidth, pageY + 4, 4, pageHeight); + + // White page + ctx.fillStyle = '#ffffff'; + ctx.fillRect(pageX, pageY, pageWidth, pageHeight); + ctx.strokeStyle = '#cccccc'; + ctx.lineWidth = 1; + ctx.strokeRect(pageX, pageY, pageWidth, pageHeight); + + // Grid (scaled by zoom factor) + ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)'; + ctx.lineWidth = 0.5; + var scaledGridSize = gridSize * zoomFactor; + for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) { + ctx.beginPath(); + ctx.moveTo(x, pageY); + ctx.lineTo(x, pageY + pageHeight); + ctx.stroke(); + } + for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) { + ctx.beginPath(); + ctx.moveTo(pageX, y); + ctx.lineTo(pageX + pageWidth, y); + ctx.stroke(); + } + + // Draw elements + console.log('Drawing', elements.length, 'elements'); + elements.forEach(function(el) { + console.log('Drawing element:', el.id, 'at', el.x, el.y, 'text:', el.text); + drawElement(el); + }); + + // Draw selection + if (selectedElement) { + drawSelection(selectedElement); + } + } + + function drawElement(el) { + ctx.save(); + + // Apply zoom factor to position and size + var x = pageX + (el.x * zoomFactor); + var y = pageY + (el.y * zoomFactor); + var w = (el.width || 100) * zoomFactor; + var h = (el.height || 30) * zoomFactor; + var fontSize = (el.fontSize || 14) * zoomFactor; + var textAlign = el.textAlign || 'left'; + + if (el.type === 'line') { + ctx.strokeStyle = el.color || '#333333'; + ctx.lineWidth = Math.max(1, zoomFactor); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + w, y); + ctx.stroke(); + } else if (el.type === 'image') { + if (el.imageData) { + // Check if image is already cached + if (imageCache[el.imageData]) { + // Use cached image for flicker-free rendering + var img = imageCache[el.imageData]; + // Calculate dimensions to maintain aspect ratio (object-fit: contain) + var imgAspect = img.width / img.height; + var boxAspect = w / h; + var drawW, drawH, drawX, drawY; + + if (imgAspect > boxAspect) { + drawW = w; + drawH = w / imgAspect; + drawX = x; + drawY = y + (h - drawH) / 2; + } else { + drawW = h * imgAspect; + drawH = h; + drawX = x + (w - drawW) / 2; + drawY = y; + } + + ctx.drawImage(img, drawX, drawY, drawW, drawH); + } else { + // Load and cache the image + var img = new Image(); + img.onload = function() { + imageCache[el.imageData] = img; + // Calculate dimensions to maintain aspect ratio + var imgAspect = img.width / img.height; + var boxAspect = w / h; + var drawW, drawH, drawX, drawY; + + if (imgAspect > boxAspect) { + drawW = w; + drawH = w / imgAspect; + drawX = x; + drawY = y + (h - drawH) / 2; + } else { + drawW = h * imgAspect; + drawH = h; + drawX = x + (w - drawW) / 2; + drawY = y; + } + + ctx.drawImage(img, drawX, drawY, drawW, drawH); + // Redraw selection if this element is selected + if (selectedElement && selectedElement.id === el.id) { + drawSelection(el); + } + }; + img.src = el.imageData; + } + } else { + // Draw placeholder + ctx.fillStyle = '#f0f0f0'; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = '#999999'; + ctx.strokeRect(x, y, w, h); + ctx.fillStyle = '#666666'; + ctx.font = Math.max(8, 12 * zoomFactor) + 'px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Bild', x + w / 2, y + h / 2); + } + } else if (el.variable === 'services.list') { + // Draw services list as a table + drawServicesTable(el, x, y, w, h, fontSize); + } else { + // Text elements + var lines = (el.text || '').split('\n'); + var lineHeight = fontSize * 1.2; + var totalTextHeight = lines.length * lineHeight; + + // Vertically center the text in the element + var ty = y + (h - totalTextHeight) / 2; + + // Draw background highlight for static elements + if (el.isStatic) { + var textWidth = ctx.measureText(el.text || '').width + (10 * zoomFactor); + // Different background colors: green for customer, blue for masterdata + if (el.isCustomer) { + ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer + } else { + ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata + } + ctx.fillRect(x - (3 * zoomFactor), y - (2 * zoomFactor), Math.max(w, textWidth), h); + } + + // Always use black text for static elements, otherwise use element color + ctx.fillStyle = el.isStatic ? '#000000' : (el.color || '#333333'); + // Masterdata elements are never bold; other elements respect their fontStyle + var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || ''); + ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px Arial'; + ctx.textBaseline = 'top'; + ctx.textAlign = textAlign; + + var textX = x; + if (textAlign === 'center') { + textX = x + (w / 2); + } else if (textAlign === 'right') { + textX = x + w; + } + + lines.forEach(function(line) { + ctx.fillText(line, textX, ty); + ty += lineHeight; + }); + } + + ctx.restore(); + } + + // Draw services list as a table with columns: Name, Steuersatz, Nettobetrag + // Plus summary section below: Nettosumme, USt, Gesamtsumme + function drawServicesTable(el, x, y, w, h, fontSize) { + var lineHeight = fontSize * 1.4; + var padding = 4 * zoomFactor; + var rowHeight = lineHeight + padding * 2; + var summaryRowHeight = fontSize * 1.6; + var summaryGap = fontSize * 0.5; + + // Column widths (percentages of total width) + var colNameWidth = w * 0.55; // 55% for Name (left-aligned) + var colVatWidth = w * 0.20; // 20% for Steuersatz (right-aligned) + var colNetWidth = w * 0.25; // 25% for Nettobetrag (right-aligned) + + var colNameX = x; + var colVatX = x + colNameWidth; + var colNetX = colVatX + colVatWidth; + + var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19; + var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%'; + + // Rows come from window.profileInvoiceServiceData when provided (system + // invoice template: the three admin price table positions); otherwise + // fall back to sample data (per-user profile invoice generator) + var serviceData = window.profileInvoiceServiceData; + var rows = (serviceData && serviceData.rows && serviceData.rows.length) + ? serviceData.rows + : [ + { name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' }, + { name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' }, + { name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' } + ]; + + // Calculate actual content height based on table + summary + var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows + var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight; + // Ensure background covers at least the element height or the calculated content + var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor); + + // Draw orange background highlight for entire service variable element + var bgPadding = 3 * zoomFactor; + ctx.fillStyle = 'rgba(255, 152, 0, 0.15)'; + ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor)); + + // Draw table header (overlays the background) + ctx.fillStyle = '#f5f5f5'; + ctx.fillRect(x, y, w, rowHeight); + + // Header border + ctx.strokeStyle = '#cccccc'; + ctx.lineWidth = Math.max(0.5, zoomFactor); + ctx.beginPath(); + ctx.moveTo(x, y + rowHeight); + ctx.lineTo(x + w, y + rowHeight); + ctx.stroke(); + + // Header text + ctx.fillStyle = '#333333'; + ctx.font = 'bold ' + fontSize + 'px Arial'; + ctx.textBaseline = 'middle'; + + // Name column header (left-aligned) + ctx.textAlign = 'left'; + ctx.fillText('Name', colNameX + padding, y + rowHeight / 2); + + // Steuersatz column header (right-aligned) + ctx.textAlign = 'right'; + ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2); + + // Nettobetrag column header (right-aligned) + ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2); + + var currentY = y + rowHeight; + + // Draw data rows + ctx.font = fontSize + 'px Arial'; + rows.forEach(function(row, index) { + // Draw row background (alternating) + if (index % 2 === 1) { + ctx.fillStyle = 'rgba(0,0,0,0.02)'; + ctx.fillRect(x, currentY, w, rowHeight); + } + + // Row bottom border + ctx.strokeStyle = '#eeeeee'; + ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); + ctx.beginPath(); + ctx.moveTo(x, currentY + rowHeight); + ctx.lineTo(x + w, currentY + rowHeight); + ctx.stroke(); + + // Draw cell text + ctx.fillStyle = '#333333'; + + // Name (left-aligned) + ctx.textAlign = 'left'; + ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2); + + // Steuersatz (right-aligned) + ctx.textAlign = 'right'; + ctx.fillText(row.vat, colVatX + colVatWidth - padding, currentY + rowHeight / 2); + + // Nettobetrag (right-aligned) + ctx.fillText(row.net, colNetX + colNetWidth - padding, currentY + rowHeight / 2); + + currentY += rowHeight; + }); + + // Draw column separator lines + ctx.strokeStyle = '#e0e0e0'; + ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); + ctx.beginPath(); + // Line between Name and Steuersatz + ctx.moveTo(colVatX, y); + ctx.lineTo(colVatX, currentY); + // Line between Steuersatz and Nettobetrag + ctx.moveTo(colNetX, y); + ctx.lineTo(colNetX, currentY); + ctx.stroke(); + + // Draw outer border around table + ctx.strokeStyle = '#cccccc'; + ctx.lineWidth = Math.max(0.5, zoomFactor); + ctx.strokeRect(x, y, w, currentY - y); + + // Draw summary section below the table + var summaryY = currentY + summaryGap; + + // Column positions for summary section + var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned) + var valueX = x + w - padding; // Value column (right-aligned) + + // Totals: provided with the service data or calculated from sample data + var netTotalLabel, vatTotalLabel, grossTotalLabel; + if (serviceData && serviceData.netTotal) { + netTotalLabel = serviceData.netTotal; + vatTotalLabel = serviceData.vatTotal; + grossTotalLabel = serviceData.grossTotal; + } else { + var netTotal = 655.00; // 450 + 85 + 120 + var vatTotal = netTotal * vatRate; + netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €'; + vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €'; + grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €'; + } + + // Draw summary lines + ctx.textBaseline = 'middle'; + + // Nettosumme - label left, value right + ctx.fillStyle = '#333333'; + ctx.font = fontSize + 'px Arial'; + ctx.textAlign = 'left'; + ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2); + ctx.font = 'bold ' + fontSize + 'px Arial'; + ctx.textAlign = 'right'; + ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2); + summaryY += summaryRowHeight; + + // Umsatzsteuer - label left, value right + ctx.font = fontSize + 'px Arial'; + ctx.textAlign = 'left'; + ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2); + ctx.font = 'bold ' + fontSize + 'px Arial'; + ctx.textAlign = 'right'; + ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2); + summaryY += summaryRowHeight; + + // Gesamtsumme - label left, value right + summaryY += summaryGap; // Extra gap before total + ctx.fillStyle = '#000000'; + ctx.font = 'bold ' + (fontSize * 1.1) + 'px Arial'; + ctx.textAlign = 'left'; + ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2); + ctx.textAlign = 'right'; + ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2); + } + + function drawSelection(el) { + var x = pageX + (el.x * zoomFactor); + var y = pageY + (el.y * zoomFactor); + var w = (el.width || 100) * zoomFactor; + var h = (el.height || 30) * zoomFactor; + + // For text elements, calculate actual text dimensions + if (el.type !== 'line' && el.type !== 'image') { + var fontSize = (el.fontSize || 14) * zoomFactor; + ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; + + // For services.list, calculate table height including summary section + if (el.variable === 'services.list') { + var lineHeight = fontSize * 1.4; + var padding = 4 * zoomFactor; + var rowHeight = lineHeight + padding * 2; + var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows + var summaryRowHeight = fontSize * 1.6; + var summaryGap = fontSize * 0.5; + var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + var totalHeight = tableHeight + summaryHeight; + h = Math.max(h, totalHeight); + } else { + var lines = (el.text || '').split('\n'); + var maxLineWidth = 0; + lines.forEach(function(line) { + var lineWidth = ctx.measureText(line).width; + maxLineWidth = Math.max(maxLineWidth, lineWidth); + }); + + // Use the larger of defined width or actual text width + w = Math.max(w, maxLineWidth + (10 * zoomFactor)); + + // Calculate actual height based on number of lines + var lineHeight = fontSize * 1.2; + var textHeight = lines.length * lineHeight; + h = Math.max(h, textHeight + (6 * zoomFactor)); + } + } + + ctx.strokeStyle = '#1976d2'; + ctx.lineWidth = Math.max(1, 2 * zoomFactor); + ctx.setLineDash([5, 3]); + ctx.strokeRect(x, y, w, h); + ctx.setLineDash([]); + + // Don't show resize handles for static elements (except services.list) + if (el.isStatic && el.variable !== 'services.list') { + return; + } + + // Handles + var hs = Math.max(6, 8 * zoomFactor); // handle size scaled + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#1976d2'; + ctx.lineWidth = Math.max(1, 2 * zoomFactor); + + var positions = [ + [x - hs/2, y - hs/2], + [x + w/2 - hs/2, y - hs/2], + [x + w - hs/2, y - hs/2], + [x - hs/2, y + h/2 - hs/2], + [x + w - hs/2, y + h/2 - hs/2], + [x - hs/2, y + h - hs/2], + [x + w/2 - hs/2, y + h - hs/2], + [x + w - hs/2, y + h - hs/2] + ]; + + positions.forEach(function(pos) { + ctx.fillRect(pos[0], pos[1], hs, hs); + ctx.strokeRect(pos[0], pos[1], hs, hs); + }); + } + + function hitTest(x, y, el) { + var ex = pageX + (el.x * zoomFactor); + var ey = pageY + (el.y * zoomFactor); + var ew = (el.width || 100) * zoomFactor; + var eh = (el.height || 30) * zoomFactor; + + // For text elements, calculate actual text dimensions for hit testing + if (el.type !== 'line' && el.type !== 'image') { + var fontSize = (el.fontSize || 14) * zoomFactor; + ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; + + // For services.list, calculate table height + if (el.variable === 'services.list') { + var lineHeight = fontSize * 1.4; + var padding = 4 * zoomFactor; + var rowHeight = lineHeight + padding * 2; + var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows + var summaryRowHeight = fontSize * 1.6; + var summaryGap = fontSize * 0.5; + var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + eh = Math.max(eh, tableHeight + summaryHeight); + } else { + var lines = (el.text || '').split('\n'); + var maxLineWidth = 0; + lines.forEach(function(line) { + var lineWidth = ctx.measureText(line).width; + maxLineWidth = Math.max(maxLineWidth, lineWidth); + }); + + ew = Math.max(ew, maxLineWidth + (10 * zoomFactor)); + + var lineHeight = fontSize * 1.2; + var textHeight = lines.length * lineHeight; + eh = Math.max(eh, textHeight + (6 * zoomFactor)); + } + } + + return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh; + } + + // Resizing state + var isResizing = false; + var resizeHandle = -1; + var resizeStart = { x: 0, y: 0, width: 0, height: 0 }; + + // Helper function to check if a point is on a resize handle + function getResizeHandle(x, y, el) { + if (!el) return -1; + // Allow resizing for services.list even though it's static + if (el.isStatic && el.variable !== 'services.list') return -1; + + var ex = pageX + (el.x * zoomFactor); + var ey = pageY + (el.y * zoomFactor); + var ew = (el.width || 100) * zoomFactor; + var eh = (el.height || 30) * zoomFactor; + + // For services.list, calculate table height including summary + if (el.variable === 'services.list') { + var fontSize = (el.fontSize || 14) * zoomFactor; + var lineHeight = fontSize * 1.4; + var padding = 4 * zoomFactor; + var rowHeight = lineHeight + padding * 2; + var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows + var summaryRowHeight = fontSize * 1.6; + var summaryGap = fontSize * 0.5; + var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + eh = Math.max(eh, tableHeight + summaryHeight); + } + + var hs = Math.max(6, 8 * zoomFactor); + var halfHs = hs / 2; + + // Handle positions: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR + var handles = [ + { x: ex - halfHs, y: ey - halfHs }, + { x: ex + ew/2 - halfHs, y: ey - halfHs }, + { x: ex + ew - halfHs, y: ey - halfHs }, + { x: ex - halfHs, y: ey + eh/2 - halfHs }, + { x: ex + ew - halfHs, y: ey + eh/2 - halfHs }, + { x: ex - halfHs, y: ey + eh - halfHs }, + { x: ex + ew/2 - halfHs, y: ey + eh - halfHs }, + { x: ex + ew - halfHs, y: ey + eh - halfHs } + ]; + + for (var i = 0; i < handles.length; i++) { + if (x >= handles[i].x && x <= handles[i].x + hs && + y >= handles[i].y && y <= handles[i].y + hs) { + return i; + } + } + return -1; + } + + // Mouse events + canvas.addEventListener('mousedown', function(e) { + var rect = canvas.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + + // Check if clicking on a resize handle of selected element + if (selectedElement) { + var handle = getResizeHandle(x, y, selectedElement); + if (handle >= 0) { + pushUndoState(); + isResizing = true; + resizeHandle = handle; + resizeStart = { + x: x, + y: y, + width: selectedElement.width || 100, + height: selectedElement.height || 30, + elemX: selectedElement.x, + elemY: selectedElement.y + }; + e.preventDefault(); + return; + } + } + + // Check if clicking on an element + var clickedElement = null; + for (var i = elements.length - 1; i >= 0; i--) { + if (hitTest(x, y, elements[i])) { + clickedElement = elements[i]; + break; + } + } + + if (clickedElement) { + pushUndoState(); + selectedElement = clickedElement; + isDragging = true; + dragStart = { x: x, y: y }; + elementStart = { x: clickedElement.x, y: clickedElement.y }; + canvas.style.cursor = 'move'; + notifyElementSelected(selectedElement); + } else { + selectedElement = null; + notifyElementDeselected(); + } + + draw(); + }); + + canvas.addEventListener('mousemove', function(e) { + var rect = canvas.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + + if (isResizing && selectedElement) { + // Calculate delta in base coordinates + var dx = (x - resizeStart.x) / zoomFactor; + var dy = (y - resizeStart.y) / zoomFactor; + + var newWidth = resizeStart.width; + var newHeight = resizeStart.height; + var newX = resizeStart.elemX; + var newY = resizeStart.elemY; + + // Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR + switch(resizeHandle) { + case 0: // Top-left + newWidth = Math.max(20, resizeStart.width - dx); + newHeight = Math.max(20, resizeStart.height - dy); + newX = resizeStart.elemX + (resizeStart.width - newWidth); + newY = resizeStart.elemY + (resizeStart.height - newHeight); + break; + case 1: // Top-center + newHeight = Math.max(20, resizeStart.height - dy); + newY = resizeStart.elemY + (resizeStart.height - newHeight); + break; + case 2: // Top-right + newWidth = Math.max(20, resizeStart.width + dx); + newHeight = Math.max(20, resizeStart.height - dy); + newY = resizeStart.elemY + (resizeStart.height - newHeight); + break; + case 3: // Middle-left + newWidth = Math.max(20, resizeStart.width - dx); + newX = resizeStart.elemX + (resizeStart.width - newWidth); + break; + case 4: // Middle-right + newWidth = Math.max(20, resizeStart.width + dx); + break; + case 5: // Bottom-left + newWidth = Math.max(20, resizeStart.width - dx); + newHeight = Math.max(20, resizeStart.height + dy); + newX = resizeStart.elemX + (resizeStart.width - newWidth); + break; + case 6: // Bottom-center + newHeight = Math.max(20, resizeStart.height + dy); + break; + case 7: // Bottom-right + newWidth = Math.max(20, resizeStart.width + dx); + newHeight = Math.max(20, resizeStart.height + dy); + break; + } + + selectedElement.width = snapToGrid(newWidth); + selectedElement.height = snapToGrid(newHeight); + selectedElement.x = snapToGrid(newX); + selectedElement.y = snapToGrid(newY); + + draw(); + notifyElementSelected(selectedElement); + } else if (isDragging && selectedElement) { + // Adjust mouse movement by zoom factor to get stored coordinates + var dx = (x - dragStart.x) / zoomFactor; + var dy = (y - dragStart.y) / zoomFactor; + + var newX = elementStart.x + dx; + var newY = elementStart.y + dy; + + // Constrain to page (using base coordinates, not zoomed) + newX = Math.max(0, Math.min(newX, basePageWidth - (selectedElement.width || 100))); + newY = Math.max(0, Math.min(newY, basePageHeight - (selectedElement.height || 30))); + + // Snap to grid + selectedElement.x = snapToGrid(newX); + selectedElement.y = snapToGrid(newY); + + draw(); + notifyElementSelected(selectedElement); + } else { + // Update cursor based on resize handles or element hover + if (selectedElement && !selectedElement.isStatic) { + var handle = getResizeHandle(x, y, selectedElement); + if (handle >= 0) { + // Set cursor based on handle type + var cursors = ['nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'ew-resize', 'nesw-resize', 'ns-resize', 'nwse-resize']; + canvas.style.cursor = cursors[handle]; + } else { + var hovering = false; + for (var i = elements.length - 1; i >= 0; i--) { + if (hitTest(x, y, elements[i])) { + hovering = true; + break; + } + } + canvas.style.cursor = hovering ? 'move' : 'default'; + } + } else { + var hovering = false; + for (var i = elements.length - 1; i >= 0; i--) { + if (hitTest(x, y, elements[i])) { + hovering = true; + break; + } + } + canvas.style.cursor = hovering ? 'move' : 'default'; + } + } + }); + + canvas.addEventListener('mouseup', function() { + isDragging = false; + isResizing = false; + resizeHandle = -1; + canvas.style.cursor = 'default'; + }); + + canvas.addEventListener('mouseleave', function() { + isDragging = false; + isResizing = false; + resizeHandle = -1; + canvas.style.cursor = 'default'; + }); + + // Keyboard navigation + canvas.setAttribute('tabindex', '0'); + canvas.addEventListener('keydown', function(e) { + // Undo with Ctrl+Z / Cmd+Z (works without a selected element) + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { + window.undoProfileCanvas(); + e.preventDefault(); + return; + } + + if (!selectedElement) return; + + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) { + pushUndoState(); + } + + var moved = false; + + switch(e.key) { + case 'ArrowUp': + if (e.shiftKey) { + selectedElement.y = Math.max(0, selectedElement.y - 1); + } else { + selectedElement.y = Math.max(0, Math.floor((selectedElement.y - 1) / gridSize) * gridSize); + } + moved = true; + e.preventDefault(); + break; + case 'ArrowDown': + if (e.shiftKey) { + selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + 1); + } else { + selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), Math.ceil((selectedElement.y + 1) / gridSize) * gridSize); + } + moved = true; + e.preventDefault(); + break; + case 'ArrowLeft': + if (e.shiftKey) { + selectedElement.x = Math.max(0, selectedElement.x - 1); + } else { + selectedElement.x = Math.max(0, Math.floor((selectedElement.x - 1) / gridSize) * gridSize); + } + moved = true; + e.preventDefault(); + break; + case 'ArrowRight': + if (e.shiftKey) { + selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + 1); + } else { + selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), Math.ceil((selectedElement.x + 1) / gridSize) * gridSize); + } + moved = true; + e.preventDefault(); + break; + case 'Delete': + var index = elements.indexOf(selectedElement); + if (index > -1) { + elements.splice(index, 1); + selectedElement = null; + notifyElementDeselected(); + draw(); + } + e.preventDefault(); + break; + } + + if (moved) { + draw(); + notifyElementSelected(selectedElement); + } + }); + + // Add element function + window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) { + pushUndoState(); + elementCounter++; + var id = 'element-' + elementCounter; + + // Convert to page coordinates (adjusting for zoom factor) + var x = (dropX - pageX - 50 * zoomFactor) / zoomFactor; + var y = (dropY - pageY - 15 * zoomFactor) / zoomFactor; + x = Math.max(10, x); + y = Math.max(10, y); + x = snapToGrid(x); + y = snapToGrid(y); + + var el = { + id: id, + type: type, + x: x, + y: y, + width: 150, + height: 30, + fontSize: 14, + color: '#333333', + isStatic: isStatic || false, + variable: variable || null, + isCustomer: isCustomer || false + }; + + // Helper function to calculate height based on font size and lines + function calculateHeight(fontSize, lineCount) { + var lineHeight = fontSize * 1.2; + return Math.round(lineCount * lineHeight + 6); + } + + // Handle static elements (user data or customer data) + if (isStatic && staticText) { + el.text = staticText; + el.color = '#000000'; // Black text for static elements + el.fontStyle = 'bold'; + // For services.list, set larger dimensions for the table with summary section + if (variable === 'services.list') { + el.width = 450; + var lineHeight = el.fontSize * 1.4; + var padding = 4; + var rowHeight = lineHeight + padding * 2; + var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows + var summaryRowHeight = el.fontSize * 1.6; + var summaryGap = el.fontSize * 0.5; + var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; + var totalHeight = tableHeight + summaryHeight; + el.height = Math.round(totalHeight); + } else { + el.height = calculateHeight(el.fontSize, 1); + } + } else { + switch (type) { + case 'text': + el.text = 'Text eingeben...'; + el.height = calculateHeight(el.fontSize, 1); + break; + case 'header': + el.text = 'Überschrift'; + el.fontSize = 24; + el.fontStyle = 'bold'; + el.color = '#000000'; + el.width = 200; + el.height = calculateHeight(el.fontSize, 1); + break; + case 'date': + el.text = 'Datum: ' + new Date().toLocaleDateString('de-DE'); + el.fontSize = 12; + el.color = '#666666'; + el.height = calculateHeight(el.fontSize, 1); + break; + case 'customer': + el.text = 'Kundenname\nStraße Nr.\nPLZ Ort'; + el.fontSize = 12; + el.height = calculateHeight(el.fontSize, 3); + break; + case 'company': + el.text = 'Ihr Unternehmen\nIhre Straße\nIhre PLZ Ort'; + el.fontSize = 12; + el.height = calculateHeight(el.fontSize, 3); + break; + case 'amount': + el.text = 'Gesamtbetrag: 0,00 €'; + el.fontStyle = 'bold'; + el.width = 180; + el.height = calculateHeight(el.fontSize, 1); + break; + case 'line': + el.text = ''; + el.width = 200; + el.height = 2; + break; + case 'image': + el.text = 'Bild'; + el.width = 100; + el.height = 100; + break; + default: + el.text = label || 'Neues Element'; + el.height = calculateHeight(el.fontSize, 1); + } + } + + elements.push(el); + selectedElement = el; + draw(); + notifyElementSelected(el); + + // Focus canvas for keyboard navigation + canvas.focus(); + }; + + // Update element functions + window.updateProfileElementText = function(id, text) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + el.text = text; + draw(); + } + }; + + window.updateProfileElementPosition = function(id, x, y) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + if (x !== null) el.x = snapToGrid(x); + if (y !== null) el.y = snapToGrid(y); + draw(); + } + }; + + window.updateProfileElementFontSize = function(id, size) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + el.fontSize = size; + // Update height based on text content and new font size + if (el.type !== 'line' && el.type !== 'image') { + var lines = (el.text || '').split('\n'); + var lineHeight = size * 1.2; + var textHeight = lines.length * lineHeight; + el.height = Math.max(textHeight + 6, 20); // Minimum height of 20 + } + draw(); + } + }; + + window.updateProfileElementColor = function(id, color) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + el.color = color; + draw(); + } + }; + + window.updateProfileElementSize = function(id, width, height) { + var el = elements.find(function(e) { return e.id === id; }); + if (el) { + pushUndoState(); + if (width !== null) el.width = width; + if (height !== null) el.height = height; + draw(); + } + }; + + window.updateProfileElementImage = function(id, imageData) { + var el = elements.find(function(e) { return e.id === id; }); + if (!el) return; + + // Load image to get dimensions + var img = new Image(); + img.onload = function() { + pushUndoState(); + var MAX_WIDTH = 1090; + var width = img.width; + var height = img.height; + + // Only resize if image is larger than MAX_WIDTH + if (width > MAX_WIDTH) { + height = Math.round(height * (MAX_WIDTH / width)); + width = MAX_WIDTH; + + // Create canvas to resize image + var canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + var ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + // Get resized image data (maintain original format if possible) + var resizedDataUrl = canvas.toDataURL('image/jpeg', 0.9); + el.imageData = resizedDataUrl; + } else { + el.imageData = imageData; + } + + el.text = 'Bild'; + draw(); + }; + img.src = imageData; + }; + + window.deleteProfileElement = function(id) { + var index = elements.findIndex(function(e) { return e.id === id; }); + if (index > -1) { + pushUndoState(); + elements.splice(index, 1); + if (selectedElement && selectedElement.id === id) { + selectedElement = null; + notifyElementDeselected(); + } + draw(); + } + }; + + // Save state to global before functions are defined + function saveState() { + window.profileInvoiceState.elements = elements; + window.profileInvoiceState.selectedElement = selectedElement; + window.profileInvoiceState.elementCounter = elementCounter; + } + + // Clear canvas function + window.clearProfileCanvas = function() { + if (elements.length) { + pushUndoState(); + } + // Keep the same array reference (shared with profileInvoiceState and undo) + elements.length = 0; + selectedElement = null; + saveState(); + notifyElementDeselected(); + draw(); + }; + + // Get canvas data + // Helper functions for percentage conversion + function toPercentX(value) { + return (value / basePageWidth * 100).toFixed(2); + } + function toPercentY(value) { + return (value / basePageHeight * 100).toFixed(2); + } + function fromPercentX(percent) { + return Math.round(parseFloat(percent) / 100 * basePageWidth); + } + function fromPercentY(percent) { + return Math.round(parseFloat(percent) / 100 * basePageHeight); + } + + window.getProfileCanvasData = function() { + saveState(); + // Convert pixel values to percentages for storage + var elementsWithPercent = elements.map(function(el) { + // For static elements with variables, handle text storage differently: + // - masterdata variables: text is resolved from user data, don't store + // - customer variables: store the placeholder text from canvas + var textToStore = el.text; + if (el.isStatic && el.variable) { + if (el.variable.startsWith('masterdata.')) { + // Don't store the text for masterdata variables - will be resolved from user data + textToStore = null; + } + // For customer variables, keep the text (placeholder) as is + } + + return { + id: el.id, + type: el.type, + text: textToStore, + xPercent: toPercentX(el.x), + yPercent: toPercentY(el.y), + widthPercent: toPercentX(el.width), + heightPercent: toPercentY(el.height), + fontSize: el.fontSize, + fontStyle: el.fontStyle, + textAlign: el.textAlign, + color: el.color, + isStatic: el.isStatic, + isCustomer: el.isCustomer, + variable: el.variable, + imageData: el.imageData + }; + }); + return { + elements: elementsWithPercent + }; + }; + + window.updateProfileVatRate = function(rate) { + if (rate == null || isNaN(rate)) return; + window.profileInvoiceVatRate = rate; + draw(); + }; + + window.updateProfileMasterdataValue = function(key, value) { + if (!window.masterdataValues) window.masterdataValues = {}; + window.masterdataValues[key] = value; + elements.forEach(function(el) { + if (el.variable === key) { + el.text = value; + } + }); + draw(); + }; + + draw(); + window.profileInvoiceState.initialized = true; + console.log('Profile canvas initialized with ' + elements.length + ' elements'); + + // Handle window resize + window.addEventListener('resize', function() { + var newRect = container.getBoundingClientRect(); + if (newRect.width > 0 && newRect.height > 0) { + canvas.width = newRect.width; + canvas.height = newRect.height; + draw(); + } + }); + + // Setup drop zone + if (!container._dropSetup) { + container._dropSetup = true; + + container.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + container.style.borderColor = 'var(--lumo-primary-color)'; + }); + + container.addEventListener('dragleave', function(e) { + container.style.borderColor = 'var(--lumo-contrast-20pct)'; + }); + + container.addEventListener('drop', function(e) { + e.preventDefault(); + container.style.borderColor = 'var(--lumo-contrast-20pct)'; + + var templateType = e.dataTransfer.getData('template-type'); + var templateLabel = e.dataTransfer.getData('template-label'); + var isStatic = e.dataTransfer.getData('is-static') === 'true'; + var staticText = e.dataTransfer.getData('static-text'); + var variable = e.dataTransfer.getData('variable'); + var isCustomer = e.dataTransfer.getData('is-customer') === 'true'; + + if (templateType && window.addProfileElement) { + var rect = container.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + window.addProfileElement(templateType, templateLabel, x, y, isStatic, staticText, variable, isCustomer); + } + }); + } + + // Load template function - defined INSIDE initProfileInvoiceGenerator to access closure variables + window.loadProfileTemplate = function(templateData) { + try { + console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200)); + var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData; + if (data.elements && Array.isArray(data.elements)) { + console.log('Loading ' + data.elements.length + ' elements'); + console.log('Current elements array before clear:', elements.length); + // Clear existing elements - use length = 0 to keep the same array reference + elements.length = 0; + selectedElement = null; + console.log('Elements array after clear:', elements.length); + + // Master data mapping - these should be filled from user data + var masterdata = window.masterdataValues || {}; + + // Load new elements + data.elements.forEach(function(el) { + console.log('Loading element:', el.id, 'type:', el.type, 'variable:', el.variable); + // Ensure element has an ID + if (!el.id) { + elementCounter++; + el.id = 'element-' + elementCounter; + } + + // Convert percentages to pixels if they exist, otherwise use legacy pixel values + if (el.xPercent !== undefined) { + el.x = fromPercentX(el.xPercent); + } + if (el.yPercent !== undefined) { + el.y = fromPercentY(el.yPercent); + } + if (el.widthPercent !== undefined) { + el.width = fromPercentX(el.widthPercent); + } + if (el.heightPercent !== undefined) { + el.height = fromPercentY(el.heightPercent); + } + + // Handle elements with variables + if (el.variable) { + el.isStatic = true; + if (el.variable.startsWith('customer.')) { + el.isCustomer = true; + // Customer variables - use saved text if available, otherwise use placeholder + if (!el.text || el.text.trim() === '') { + el.text = '[' + el.variable.replace('customer.', '') + ']'; + } + // If text is already set (from saved template), keep it + } else if (el.variable.startsWith('masterdata.')) { + // Masterdata variables - use actual value from masterdata or placeholder + var value = masterdata[el.variable]; + if (value && value.trim() !== '') { + el.text = value; + } else { + el.text = '[' + el.variable.replace('masterdata.', '') + ']'; + } + } + } + // Legacy: If text contains a variable placeholder {{variable}}|Display Text, extract it + else if (el.text && el.text.startsWith('{{')) { + var pipeIndex = el.text.indexOf('|'); + if (pipeIndex > -1) { + // Format: {{variable}}|Display Text + var placeholderPart = el.text.substring(0, pipeIndex); + var displayText = el.text.substring(pipeIndex + 1); + if (placeholderPart.startsWith('{{') && placeholderPart.endsWith('}}')) { + var varName = placeholderPart.substring(2, placeholderPart.length - 2); + el.variable = varName; + if (varName.startsWith('masterdata.')) { + el.isStatic = true; + // Use masterdata value or fallback to display text + var value = masterdata[varName]; + el.text = (value && value.trim() !== '') ? value : displayText; + } else if (varName.startsWith('customer.')) { + el.isStatic = true; + el.isCustomer = true; + // Use saved text if available, otherwise use placeholder + if (!el.text || el.text.trim() === '' || el.text.startsWith('{{')) { + el.text = '[' + varName.replace('customer.', '') + ']'; + } + } + } + } else if (el.text.endsWith('}}')) { + // Legacy format: {{variable}} without display text + var varName = el.text.substring(2, el.text.length - 2); + el.variable = varName; + if (varName.startsWith('masterdata.')) { + el.isStatic = true; + var value = masterdata[varName]; + el.text = (value && value.trim() !== '') ? value : '[' + varName.replace('masterdata.', '') + ']'; + } else if (varName.startsWith('customer.')) { + el.isStatic = true; + el.isCustomer = true; + el.text = '[' + varName.replace('customer.', '') + ']'; + } + } + } + + console.log('Element processed:', el.id, 'x:', el.x, 'y:', el.y, 'text:', el.text); + elements.push(el); + console.log('Element pushed, elements count now:', elements.length); + }); + + // Update counter to be higher than any loaded element + elements.forEach(function(el) { + if (el.id && el.id.startsWith('element-')) { + var num = parseInt(el.id.replace('element-', '')); + if (!isNaN(num) && num > elementCounter) { + elementCounter = num; + } + } + }); + + // Save to global state + saveState(); + + console.log('Calling draw(), elements count:', elements.length); + console.log('Canvas dimensions:', canvas.width, 'x', canvas.height); + draw(); + console.log('draw() completed'); + notifyElementDeselected(); + console.log('Template loaded with ' + elements.length + ' elements'); + } else { + console.log('No elements array found in template data'); + } + } catch (e) { + console.error('Error loading template:', e); + } + }; +}; diff --git a/src/main/frontend/styles/invoice-generator.css b/src/main/frontend/styles/invoice-generator.css new file mode 100644 index 0000000..66a6a11 --- /dev/null +++ b/src/main/frontend/styles/invoice-generator.css @@ -0,0 +1,77 @@ +/* Styles für den Rechnungsgenerator (Canvas-Template-Editor). + Angelehnt an das votianlt-Theme, reduziert auf Lumo-Variablen. */ + +.invoice-generator-view { + gap: var(--lumo-space-m); +} + +.invoice-generator-main { + gap: var(--lumo-space-m); +} + +.invoice-generator-panel { + border: 1px solid var(--lumo-contrast-20pct); + border-radius: var(--lumo-border-radius-l); + background: var(--lumo-base-color); + box-shadow: var(--lumo-box-shadow-xs); + box-sizing: border-box; +} + +.invoice-generator-panel-title { + color: var(--lumo-header-text-color); + font-size: var(--lumo-font-size-m); + font-weight: 700; +} + +.invoice-generator-info { + color: var(--lumo-secondary-text-color); + font-size: var(--lumo-font-size-s); +} + +.invoice-generator-template { + display: flex; + align-items: center; + gap: var(--lumo-space-s); + margin: var(--lumo-space-xs) 0; + padding: var(--lumo-space-s) var(--lumo-space-m); + border: 1px solid var(--lumo-contrast-20pct); + border-radius: var(--lumo-border-radius-m); + background: var(--lumo-contrast-5pct); + cursor: grab; + user-select: none; + font-size: var(--lumo-font-size-s); + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +.invoice-generator-template:hover { + box-shadow: var(--lumo-box-shadow-xs); + border-color: var(--lumo-primary-color-50pct); +} + +.invoice-generator-canvas { + position: relative; + overflow: hidden; + cursor: default; + border: 2px dashed var(--lumo-contrast-30pct); + border-radius: var(--lumo-border-radius-l); + background: var(--lumo-base-color); +} + +.invoice-generator-actionbar { + width: 100%; + padding: var(--lumo-space-s) var(--lumo-space-m); + border: 1px solid var(--lumo-contrast-20pct); + border-radius: var(--lumo-border-radius-l); + background: var(--lumo-base-color); + box-sizing: border-box; +} + +.invoice-generator-pdf { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.invoice-generator-frame { + border: none; +} diff --git a/src/main/java/de/assecutor/pdftool/PdfToolApplication.java b/src/main/java/de/assecutor/pdftool/PdfToolApplication.java new file mode 100644 index 0000000..f4b68a5 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/PdfToolApplication.java @@ -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); + } +} diff --git a/src/main/java/de/assecutor/pdftool/address/Address.java b/src/main/java/de/assecutor/pdftool/address/Address.java new file mode 100644 index 0000000..103d2f7 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/address/Address.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/pdftool/address/AddressRepository.java b/src/main/java/de/assecutor/pdftool/address/AddressRepository.java new file mode 100644 index 0000000..5e24627 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/address/AddressRepository.java @@ -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 { + + List
findByType(AddressType type); +} diff --git a/src/main/java/de/assecutor/pdftool/address/AddressType.java b/src/main/java/de/assecutor/pdftool/address/AddressType.java new file mode 100644 index 0000000..e922da8 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/address/AddressType.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java b/src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java new file mode 100644 index 0000000..63219d6 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java @@ -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. + * + *
+ * 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"
+ * 
+ */ +@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 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 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> 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> handleConversionError(ZugferdConversionException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/CapturedInvoiceData.java b/src/main/java/de/assecutor/pdftool/invoice/CapturedInvoiceData.java new file mode 100644 index 0000000..0dbe7de --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/CapturedInvoiceData.java @@ -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 items = List.of(); + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items != null ? List.copyOf(items) : List.of(); + } +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraft.java b/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraft.java new file mode 100644 index 0000000..99717e0 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraft.java @@ -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 items, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraftRepository.java b/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraftRepository.java new file mode 100644 index 0000000..d5f8a5a --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraftRepository.java @@ -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 { + + Optional findByName(String name); + + boolean existsByName(String name); +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraftService.java b/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraftService.java new file mode 100644 index 0000000..6dde330 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/InvoiceDraftService.java @@ -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 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 names() { + return repository.findAll().stream() + .map(draft -> draft.name()) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java b/src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java new file mode 100644 index 0000000..0e02654 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java @@ -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 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 + ) { + } +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/ValidVatId.java b/src/main/java/de/assecutor/pdftool/invoice/ValidVatId.java new file mode 100644 index 0000000..3f53bf2 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/ValidVatId.java @@ -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[] payload() default {}; + + class Validator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) { + return true; + } + Optional error = VatIdValidator.validate(value); + if (error.isPresent()) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(error.get()).addConstraintViolation(); + return false; + } + return true; + } + } +} diff --git a/src/main/java/de/assecutor/pdftool/invoice/VatIdValidator.java b/src/main/java/de/assecutor/pdftool/invoice/VatIdValidator.java new file mode 100644 index 0000000..d5f26a8 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/VatIdValidator.java @@ -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 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 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)); + } +} diff --git a/src/main/java/de/assecutor/pdftool/template/InvoiceTemplate.java b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplate.java new file mode 100644 index 0000000..88ef184 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplate.java @@ -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; + } +} diff --git a/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateRepository.java b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateRepository.java new file mode 100644 index 0000000..0d39919 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateRepository.java @@ -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 { + + Optional findByName(String name); + + boolean existsByName(String name); +} diff --git a/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java new file mode 100644 index 0000000..1b3a655 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/InvoiceTemplateService.java @@ -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. + * + *

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.

+ */ +@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 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 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 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()); + } + } +} diff --git a/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java b/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java new file mode 100644 index 0000000..7c56c1d --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/MongoConnectionCheck.java @@ -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)); + } +} diff --git a/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java b/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java new file mode 100644 index 0000000..b6cdde1 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java @@ -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 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 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(""); + htmlBuilder.append(""); + htmlBuilder.append(""); + htmlBuilder.append(""); + htmlBuilder.append(""); + + if (elements != null && elements.isArray()) { + for (JsonNode element : elements) { + appendElement(htmlBuilder, element, variables, effectiveVatRate); + } + } + + htmlBuilder.append(""); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + HtmlConverter.convertToPdf(htmlBuilder.toString(), out); + return out.toByteArray(); + } + + private void appendElement(StringBuilder htmlBuilder, JsonNode element, Map 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("
"); + + // 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", "
"); + } 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( + "
"); + htmlBuilder.append("Bild"); + htmlBuilder.append("
"); + } else { + htmlBuilder.append( + "
[Bild]
"); + } + } else if ("services.list".equals(variable)) { + if (variables.containsKey("services.json")) { + htmlBuilder.append(generateServicesTableHtmlWithData(variables)); + } else { + htmlBuilder.append(generateServicesTableHtml(effectiveVatRate)); + } + } else if (text.contains("
")) { + // Mehrzeiliger Text: ohne nowrap rendern, damit
wirkt + htmlBuilder.append("").append(text).append(""); + } else { + htmlBuilder.append("").append(text).append(""); + } + + htmlBuilder.append("
"); + } + + /** 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("
"); + html.append(""); + 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(""); + html.append( + ""); + html.append(""); + html.append(""); + html.append(""); + } + html.append("
") + .append(sampleData[i][0]).append("").append(sampleData[i][1]) + .append("").append(sampleData[i][2]) + .append("
"); + + html.append("
"); + html.append(""); + html.append(summaryRow("Nettosumme:", String.format(Locale.GERMANY, "%,.2f €", netTotal), false)); + html.append(summaryRow("Gesamtsumme:", String.format(Locale.GERMANY, "%,.2f €", grossTotal), true)); + html.append("
"); + html.append("
"); + html.append("
"); + return html.toString(); + } + + /** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */ + private String generateServicesTableHtmlWithData(Map 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> 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("
"); + html.append(""); + html.append(tableHeaderRow()); + + if (servicesData.isEmpty()) { + html.append(""); + html.append( + ""); + html.append(""); + } else { + for (int i = 0; i < servicesData.size(); i++) { + Map 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(""); + html.append( + ""); + html.append(""); + // € nur an rein numerische Beträge anhängen; Werte wie "15 %" unverändert + String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + " €" : escapeHtml(netAmount); + html.append(""); + html.append(""); + } + } + html.append("
Keine Positionen vorhanden
") + .append(escapeHtml(name)).append("") + .append(escapeHtml(rowVat)).append("") + .append(amountDisplay).append("
"); + + html.append("
"); + html.append(""); + // 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("
"); + html.append("
"); + html.append("
"); + return html.toString(); + } + + private String tableHeaderRow() { + return "" + + "Name" + + "Steuersatz" + + "Nettobetrag" + + ""; + } + + 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 "" + + "" + + "" + label + "" + + "" + value + "" + + ""; + } + + private String escapeHtml(String input) { + if (input == null) { + return ""; + } + return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + .replace("'", "'"); + } +} diff --git a/src/main/java/de/assecutor/pdftool/template/TemplateRenderException.java b/src/main/java/de/assecutor/pdftool/template/TemplateRenderException.java new file mode 100644 index 0000000..b8442b4 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/TemplateRenderException.java @@ -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); + } +} diff --git a/src/main/java/de/assecutor/pdftool/template/TemplateVariables.java b/src/main/java/de/assecutor/pdftool/template/TemplateVariables.java new file mode 100644 index 0000000..3601ec0 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/template/TemplateVariables.java @@ -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 variables, List items) { + List> positions = new ArrayList<>(); + BigDecimal netTotal = BigDecimal.ZERO; + BigDecimal vatTotal = BigDecimal.ZERO; + + for (InvoiceMetadata.LineItem item : items) { + BigDecimal net = lineNet(item); + Map 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 items) { + List> rows = new ArrayList<>(); + BigDecimal netTotal = BigDecimal.ZERO; + BigDecimal vatTotal = BigDecimal.ZERO; + + for (InvoiceMetadata.LineItem item : items) { + BigDecimal net = lineNet(item); + Map 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 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 items) { + List labels = items.stream().map(item -> vatLabel(item.vatPercent())).distinct().toList(); + return labels.size() == 1 ? labels.get(0) : ""; + } +} diff --git a/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java b/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java new file mode 100644 index 0000000..e14a46b --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java @@ -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
grid = new Grid<>(Address.class, false); + + /** Filter über der Tabelle: alle Adressen oder nur ein Verwendungszweck. */ + private final ComboBox 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
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 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 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 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 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; + } +} diff --git a/src/main/java/de/assecutor/pdftool/ui/MainLayout.java b/src/main/java/de/assecutor/pdftool/ui/MainLayout.java new file mode 100644 index 0000000..4342ec2 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/ui/MainLayout.java @@ -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() : ""; + } +} diff --git a/src/main/java/de/assecutor/pdftool/ui/MainView.java b/src/main/java/de/assecutor/pdftool/ui/MainView.java new file mode 100644 index 0000000..3569383 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/ui/MainView.java @@ -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 templateSelect = new ComboBox<>("Rechnungstemplate"); + + /** Vorbelegung der Rechnungssteller-Felder aus dem Adressbuch. */ + private final ComboBox
senderAddressSelect = new ComboBox<>("Aus dem Adressbuch übernehmen"); + + /** Vorbelegung der Rechnungsempfänger-Felder aus dem Adressbuch. */ + private final ComboBox
recipientAddressSelect = new ComboBox<>("Aus dem Adressbuch übernehmen"); + + private final AddressRepository addressRepository; + private final InvoiceDraftService invoiceDraftService; + + /** Laden zuvor gespeicherter Eingaben dieser Maske. */ + private final ComboBox 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 lineItems = new ArrayList<>(); + private final Grid 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 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 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
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 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 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 errors = validateForm(); + if (!errors.isEmpty()) { + showErrors(errors); + return; + } + if (uploadedPdf != null) { + convertAndOfferDownload(uploadedPdf); + return; + } + String templateName = templateSelect.getValue(); + Optional 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 buildTemplateVariables(InvoiceMetadata metadata) { + Map 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 validateForm() { + List 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 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 errors) { + field.setErrorMessage(message); + field.setInvalid(true); + errors.add(message); + } + + private static void markInvalid(BigDecimalField field, String message, List 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 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 error = VatIdValidator.validate(value); + vatId.setErrorMessage(error.orElse(null)); + vatId.setInvalid(error.isPresent()); + return error.isEmpty(); + } + + List validateFields(String partyLabel) { + List 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() + ); + } + } + +} diff --git a/src/main/java/de/assecutor/pdftool/ui/TemplateGeneratorView.java b/src/main/java/de/assecutor/pdftool/ui/TemplateGeneratorView.java new file mode 100644 index 0000000..c83635a --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/ui/TemplateGeneratorView.java @@ -0,0 +1,900 @@ +package de.assecutor.pdftool.ui; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClientCallable; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.IFrame; +import com.vaadin.flow.component.html.Input; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.component.upload.Upload; +import com.vaadin.flow.component.upload.receivers.MemoryBuffer; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import de.assecutor.pdftool.invoice.CapturedInvoiceData; +import de.assecutor.pdftool.invoice.InvoiceMetadata; +import de.assecutor.pdftool.template.InvoiceTemplateService; +import de.assecutor.pdftool.template.TemplatePdfService; +import de.assecutor.pdftool.template.TemplateVariables; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Canvas-Designer für eigene Rechnungstemplates: Bausteine (Stammdaten, + * Empfänger, Positionen, freie Elemente) werden per Drag & Drop auf einer + * A4-Zeichenfläche platziert. Das Template wird als JSON gespeichert und kann + * als PDF-Vorschau gerendert werden. Portiert aus votianlt + * (InvoiceGeneratorView); Rechnungssteller-Stammdaten sind hier fest die + * Assecutor Data Service GmbH. + */ +@Route(value = "rechnungsgenerator", layout = MainLayout.class) +@PageTitle("Templates") +@JsModule("./invoice-generator/profile-invoice-generator.js") +@CssImport("./styles/invoice-generator.css") +public class TemplateGeneratorView extends VerticalLayout { + private static final Logger log = LoggerFactory.getLogger(TemplateGeneratorView.class); + + private static final BigDecimal VAT_RATE = new BigDecimal("0.19"); + + private final TemplatePdfService templatePdfService; + private final InvoiceTemplateService invoiceTemplateService; + private final CapturedInvoiceData capturedInvoiceData; + + private final VerticalLayout propertiesPanel; + + /** Auswahl des zu bearbeitenden Templates in der Aktionsleiste. */ + private ComboBox 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 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 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 buildVariables() { + Map 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 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 effectiveItems() { + List items = capturedInvoiceData.getItems(); + return items.isEmpty() ? SAMPLE_ITEMS : items; + } + + private void addPositions(Map 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 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); + } +} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java new file mode 100644 index 0000000..53dd73f --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java @@ -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); + } +} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java new file mode 100644 index 0000000..0513bfe --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java @@ -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) { +} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java new file mode 100644 index 0000000..79037d1 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java @@ -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()); + } +} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java new file mode 100644 index 0000000..110f287 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java @@ -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, + "Validierung nicht durchführbar: " + e.getMessage() + ""); + } + } + + public record ValidationResult(boolean valid, String reportXml) { + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..b8ee4af --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,17 @@ +spring.application.name=pdf-tool +server.port=8080 + +spring.servlet.multipart.max-file-size=25MB +spring.servlet.multipart.max-request-size=30MB + +vaadin.launch-browser=false + +# Zugangsdaten aus der .env-Datei im Projektverzeichnis laden (falls vorhanden) +spring.config.import=optional:file:.env[.properties] + +# MongoDB: Ablage der Rechnungstemplates. Die Verbindung ist zwingend -- +# ohne erreichbaren Server bricht der Start ab (MongoConnectionCheck, mit +# klarer Fehlermeldung; deshalb kein auto-index-creation). +spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017/pdf-tool} +spring.data.mongodb.connect-timeout=10000 +spring.data.mongodb.socket-timeout=30000 diff --git a/src/test/java/de/assecutor/pdftool/invoice/VatIdValidatorTest.java b/src/test/java/de/assecutor/pdftool/invoice/VatIdValidatorTest.java new file mode 100644 index 0000000..484175b --- /dev/null +++ b/src/test/java/de/assecutor/pdftool/invoice/VatIdValidatorTest.java @@ -0,0 +1,48 @@ +package de.assecutor.pdftool.invoice; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class VatIdValidatorTest { + + @ParameterizedTest + @ValueSource(strings = { + "DE261094748", // Assecutor Data Service GmbH + "DE136695976", // gültige Prüfziffer + "de 261 094 748", // Leerzeichen und Kleinschreibung werden normalisiert + "ATU12345678", + "NL123456789B01", + "FRXX123456789" + }) + void accepts_valid_vat_ids(String vatId) { + assertThat(VatIdValidator.validate(vatId)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "DE261094749", // falsche Prüfziffer + "DE12345678", // zu kurz + "DE1234567890", // zu lang + "XX123456789", // unbekanntes Länderpräfix + "123456789", // Länderpräfix fehlt + "ATU1234567", // falsches Format für AT + "DE" // nur Präfix + }) + void rejects_invalid_vat_ids(String vatId) { + assertThat(VatIdValidator.validate(vatId)).isPresent(); + } + + @Test + void rejects_blank_values() { + assertThat(VatIdValidator.validate(null)).isPresent(); + assertThat(VatIdValidator.validate(" ")).isPresent(); + } + + @Test + void normalizes_separators_and_case() { + assertThat(VatIdValidator.normalize("de 261-094.748")).isEqualTo("DE261094748"); + } +} diff --git a/src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java b/src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java new file mode 100644 index 0000000..3eb2838 --- /dev/null +++ b/src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java @@ -0,0 +1,63 @@ +package de.assecutor.pdftool.template; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TemplatePdfServiceTest { + + private final TemplatePdfService service = new TemplatePdfService(); + + @Test + void rendersCanvasTemplateWithVariablesAndPositionsToPdf() { + String template = """ + {"elements": [ + {"id": "e1", "type": "text", "variable": "masterdata.company_name", + "xPercent": 10, "yPercent": 5, "widthPercent": 40, "heightPercent": 3}, + {"id": "e2", "type": "text", "text": "Freier Text", + "xPercent": 10, "yPercent": 10, "widthPercent": 40, "heightPercent": 3}, + {"id": "e3", "type": "line", "xPercent": 10, "yPercent": 15, "widthPercent": 80, "heightPercent": 0}, + {"id": "e4", "type": "text", "variable": "services.list", + "xPercent": 10, "yPercent": 20, "widthPercent": 80, "heightPercent": 20} + ]}"""; + Map variables = Map.of( + "masterdata.company_name", "Assecutor Data Service GmbH", + "services.json", + "[{\"name\":\"Beratung\",\"netAmount\":\"100,00\",\"vat\":\"19%\"}," + + "{\"name\":\"Fachbuch\",\"netAmount\":\"50,00\",\"vat\":\"7%\"}]", + "invoice.net_total", "150,00 €", + "invoice.vat_total", "22,50 €", + "invoice.gross_total", "172,50 €", + "invoice.vat_rate", ""); + + byte[] pdf = service.generatePdf(template, variables, new BigDecimal("0.19")); + + assertTrue(pdf.length > 500, "PDF sollte Inhalt haben"); + assertArrayEquals("%PDF-".getBytes(StandardCharsets.US_ASCII), + java.util.Arrays.copyOfRange(pdf, 0, 5), "Ergebnis muss ein PDF sein"); + } + + @Test + void legacyPixelCoordinatesAreConverted() { + String template = """ + {"elements": [ + {"id": "e1", "type": "text", "text": "Legacy", "x": 100, "y": 100, "width": 200, "height": 30} + ]}"""; + + byte[] pdf = service.generatePdf(template, Map.of(), null); + + assertTrue(pdf.length > 500); + } + + @Test + void invalidJsonThrowsTemplateRenderException() { + assertThrows(TemplateRenderException.class, + () -> service.generatePdf("kein json", Map.of(), null)); + } +} diff --git a/src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java b/src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java new file mode 100644 index 0000000..5505860 --- /dev/null +++ b/src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java @@ -0,0 +1,60 @@ +package de.assecutor.pdftool.template; + +import de.assecutor.pdftool.invoice.InvoiceMetadata; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TemplateVariablesTest { + + private static InvoiceMetadata.LineItem item(String description, String quantity, String unitPrice, String vat) { + return new InvoiceMetadata.LineItem(description, new BigDecimal(quantity), new BigDecimal(unitPrice), + new BigDecimal(vat)); + } + + @Test + void addPositionsComputesTotalsFromQuantityAndUnitPrice() { + Map variables = new HashMap<>(); + + TemplateVariables.addPositions(variables, List.of( + item("Beratung", "2", "100.00", "19"), + item("Fachbuch", "1", "50.00", "19"))); + + assertEquals("250,00 €", variables.get("invoice.net_total")); + assertEquals("47,50 €", variables.get("invoice.vat_total")); + assertEquals("297,50 €", variables.get("invoice.gross_total")); + assertEquals("19%", variables.get("invoice.vat_rate")); + assertEquals("2", variables.get("services.count")); + assertTrue(variables.get("services.json").contains("\"netAmount\":\"200,00\""), + "Nettobetrag der Position muss Menge x Einzelpreis sein"); + } + + @Test + void mixedVatRatesYieldEmptyUniformLabelAndPerRowVat() { + Map variables = new HashMap<>(); + + TemplateVariables.addPositions(variables, List.of( + item("Beratung", "1", "100.00", "19"), + item("Fachbuch", "1", "50.00", "7"))); + + assertEquals("", variables.get("invoice.vat_rate")); + assertEquals("22,50 €", variables.get("invoice.vat_total")); + assertTrue(variables.get("services.json").contains("\"vat\":\"19%\"")); + assertTrue(variables.get("services.json").contains("\"vat\":\"7%\"")); + } + + @Test + void canvasPositionsJsonContainsRowsAndTotals() { + String json = TemplateVariables.canvasPositionsJson(List.of(item("Beratung", "1", "100.00", "19"))); + + assertTrue(json.contains("\"rows\"")); + assertTrue(json.contains("\"netTotal\":\"100,00 €\"")); + assertTrue(json.contains("\"grossTotal\":\"119,00 €\"")); + } +} diff --git a/src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java b/src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java new file mode 100644 index 0000000..2742900 --- /dev/null +++ b/src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java @@ -0,0 +1,133 @@ +package de.assecutor.pdftool.zugferd; + +import de.assecutor.pdftool.invoice.InvoiceMetadata; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZugferdServiceTest { + + private final ZugferdService service = new ZugferdService(new ZugferdValidationService()); + + @Test + void createsZipWithZugferdPdfAndFacturXml() throws Exception { + byte[] sourcePdf = createSamplePdf(); + + ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata()); + + assertEquals("RE-2026-0815.zip", result.zipFileName()); + assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport()); + + Map entries = readZip(result.zip()); + assertEquals(3, entries.size()); + byte[] pdf = entries.get("RE-2026-0815-zugferd.pdf"); + byte[] xml = entries.get("factur-x.xml"); + byte[] report = entries.get("validation-report.xml"); + assertNotNull(pdf, "ZUGFeRD-PDF fehlt im ZIP"); + assertNotNull(xml, "Factur-X-XML fehlt im ZIP"); + assertNotNull(report, "Prüfbericht fehlt im ZIP"); + + String xmlText = new String(xml, StandardCharsets.UTF_8); + assertTrue(xmlText.contains("RE-2026-0815"), "Rechnungsnummer fehlt im XML"); + assertTrue(xmlText.contains("CrossIndustryInvoice"), "Kein CII-XML"); + assertTrue(xmlText.contains("urn:cen.eu:en16931:2017"), "EN16931-Profil fehlt"); + + // Das erzeugte PDF muss das XML als eingebettete Datei enthalten + try (PDDocument doc = Loader.loadPDF(pdf)) { + String names = doc.getDocumentCatalog().getNames().getEmbeddedFiles().getNames().keySet().toString(); + assertTrue(names.contains("factur-x.xml"), "factur-x.xml nicht ins PDF eingebettet: " + names); + } + } + + @Test + void convertsPdfWhosePageHasNoResources() throws Exception { + // Mustang 2.17 stürzt ohne Resources-Dictionary mit einer NPE ab — + // der Service muss solche PDFs vorab reparieren. + byte[] sourcePdf; + try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + doc.addPage(new PDPage()); + doc.save(out); + sourcePdf = out.toByteArray(); + } + + ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata()); + + assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport()); + } + + @Test + void rejectsNonPdfInput() { + assertThrows(ZugferdConversionException.class, + () -> service.createZugferdZip("kein pdf".getBytes(StandardCharsets.UTF_8), sampleMetadata())); + } + + private static InvoiceMetadata sampleMetadata() { + return new InvoiceMetadata( + "RE-2026-0815", + LocalDate.of(2026, 7, 8), + LocalDate.of(2026, 7, 1), + LocalDate.of(2026, 7, 22), + "EUR", + "Zahlbar innerhalb von 14 Tagen ohne Abzug.", + "KR-2026-042", + "DE75512108001245126199", + null, + new InvoiceMetadata.Party("Assecutor Data Service GmbH", "Gerhart-Hauptmann-Weg 14", + "21502", "Geesthacht", "DE", "DE261094748", "rechnung@example.com", + "+49 40 18 123 771 0"), + new InvoiceMetadata.Party("Kunde AG", "Beispielweg 2", "10115", "Berlin", "DE", null, + "einkauf@example.com", null), + List.of(new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE, + new BigDecimal("1500.00"), new BigDecimal("19"))) + ); + } + + private static byte[] createSamplePdf() throws Exception { + try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + // PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei + PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream( + "/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true); + cs.beginText(); + cs.setFont(font, 12); + cs.newLineAtOffset(50, 700); + cs.showText("Rechnung RE-2026-0815"); + cs.endText(); + } + doc.save(out); + return out.toByteArray(); + } + } + + private static Map readZip(byte[] zip) throws Exception { + Map entries = new HashMap<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.put(entry.getName(), zis.readAllBytes()); + } + } + return entries; + } +}