Compare commits

..

3 Commits

Author SHA1 Message Date
31b18e1f52 refactor: E-Rechnungs-Modul, Freigabe-Workflow und DATEV-Export entfernt
Entfernt ZUGFeRD/Factur-X-Anreicherung (Mustangproject), PAdES-Signatur
(BouncyCastle/DSS) inkl. nutzerseitiger Keystore-Verwaltung, den
Approval-Workflow für Storno-/Berichtigungsbelege sowie den DATEV-CSV-Export.
Navigation kehrt zur klassischen Rechnungsansicht zurück; Version auf 0.9.17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 08:43:13 +02:00
d699609aa1 feat: E-Rechnungs-Backend, Pflichtangaben-Validator, Nummern-Audit und DATEV-Export
Größerer Wurf rund um Rechnungen: das vollständige E-Rechnungs- und
Lifecycle-Backend wird eingecheckt, die UI-seitige Rechnungserstellung wird
zugunsten eines DATEV-Exports zurückgebaut, und die Test-Infrastruktur wird
auf JDK 25 angehoben.

E-Rechnung & Signatur
- ZUGFeRD/Factur-X/XRechnung-Anreicherung via Mustang (EInvoiceService).
- PAdES-Detached-Signatur via PDFBox + BouncyCastle, System- und Nutzer-
  Keystore-Pfad, Nutzer-Keystores AES-GCM-verschlüsselt (AesGcmCipher,
  SigningCredentialsService, UserSigningCredentials).
- Konfiguration via EInvoiceProperties + EInvoiceSecurityInitializer.
- Approval-Workflow für kritische Rechnungsvorgänge (Storno, Berichtigung):
  InvoiceApprovalService, InvoiceApprovalRequest, ApprovalsView,
  InvoicePermissionService, InvoiceRoles.

Lifecycle & Audit
- InvoiceStatus DRAFT/ISSUED/SENT/CANCELLED/CORRECTED, InvoiceType
  INVOICE/CORRECTION/CANCELLATION, PaymentStatus, lückenloser Audit-Trail
  via InvoiceAuditEntry/Action.
- InvoiceLifecycleService verwaltet alle Übergänge inklusive Storno- und
  Berichtigungsbelegen mit Querverweis zur Originalrechnung.
- InvoiceLifecycleMigration zieht Bestandsdaten in das neue Lifecycle-Modell.
- InvoiceExportService bündelt Original + Folgebelege als ZIP für die
  Auslieferung an den Steuerberater.

Pflichtangaben-Validator (§ 14 UStG)
- InvoiceComplianceValidator + Exception sammeln alle Verstöße in einem
  Lauf (Pflichtfelder, Adressen, Steuernummer/USt-IdNr, Items, Betrags-
  konsistenz, Hinweispflicht bei 0 % USt).
- Wird vor jedem DRAFT-→-ISSUED-Übergang im Lifecycle aufgerufen, sodass
  festgeschriebene Rechnungen keine Pflichtfeld-Lücken mehr haben können.

Rechnungsnummer-Audit (§ 14 Abs. 4 Nr. 4 UStG / GoBD)
- InvoiceNumberReservation + Status RESERVED/USED/VOIDED protokollieren
  jede aus dem Counter gezogene Nummer.
- UserInvoiceDataService schreibt bei Vergabe ein RESERVED-Audit, der
  Lifecycle setzt nach Festschreiben USED bzw. nach Löschen eines Entwurfs
  VOIDED — Lücken im Nummernkreis sind dadurch erklärbar.
- InvoiceNumberAuditService liefert markUsed/markVoided/findUnused für
  Folge-UI und Betriebsprüfungs-Reports.

UI-Rückbau und DATEV-Export
- Routen für CreateInvoiceView, InvoicesView, MyInvoicesView auskommentiert
  (Code bleibt erhalten, Reaktivierung dokumentiert).
- Rechnungs-Buttons aus ShowJobsView entfernt, Nav-Eintrag „Rechnungen"
  durch „DATEV-Export" ersetzt.
- DatevExportService erzeugt einen DATEV-EXTF-Buchungsstapel (Version 7,
  Windows-1252, CRLF) mit SKR03-Erlöskonten (8400/8300/8125), Sammel-
  debitor 10000 und korrektem S/H-Verhalten für Stornorechnungen.
- DatevExportView mit Zeitraum-Picker und Auto-Download.
- i18n-Keys (de/en) für nav.datev.export und datev.export.*.

Tests & Build
- EInvoiceServiceTest (Signatur-Pfade), EInvoiceServiceDssValidationTest
  (PAdES-Profil via EU DSS 6.2 — dokumentiert PKCS7-B als Ist-Stand),
  InvoiceComplianceValidatorTest (26 Cases als Spezifikation der
  Pflichtangaben), InvoiceNumberAuditServiceTest, DatevExportServiceTest.
- Mockito 5.18 + ByteBuddy 1.17.5 in <dependencyManagement> gepinnt;
  die Spring-Boot-3.4.3-Defaults (Mockito 5.14.2 / ByteBuddy 1.15.11)
  konnten den Inline-Mock-Maker auf JDK 25 nicht laden, weshalb die
  beiden DemoModeServiceTests vorher rot waren.
- DSS-Test-Dependencies (dss-pades-pdfbox, dss-validation,
  dss-utils-apache-commons, dss-crl-parser-x509crl 6.2) im Test-Scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:34:04 +02:00
5ac629c23d feat: Kundenauswahl vereinheitlicht und Job manuell beenden mit Leistungs-/Routenerfassung
- Kunden-Repository liefert auch Legacy-Dokumente ohne internal-Flag ($ne: true)
- Auftraggeber- und Abholadress-Labels über neuen CustomerAddressLabelHelper, zeigen nur Firmenname bzw. Vor-/Nachname ohne Adresszusatz
- Pickup-Dialog: E-Mail ist kein Pflichtfeld mehr
- JobManualCompleteView erhält Route-/Leistungen-/Zusammenfassung-/Bemerkung-Block mit Vorbelegung aus dem Auftrag; bei fehlenden Routendaten manuelle Eingabe von Entfernung und Dauer, die in die Preisermittlung einfliessen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:11:08 +02:00
48 changed files with 4237 additions and 323 deletions

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.16</revision> <revision>0.9.17</revision>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
@@ -44,6 +44,31 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<!-- Mockito + ByteBuddy hochziehen, weil die Spring-Boot-3.4.3-Defaults
(Mockito 5.14.2 / ByteBuddy 1.15.11) den Inline-Mock-Maker auf
JDK 25 nicht laden können — die JVM lehnt die Class-Modifikation
von java.lang.Object ab. Mockito 5.18 + ByteBuddy 1.17.5 sind die
erste stabile Kombination, die JDK 25 offiziell trägt. -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.18.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.18.0</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.17.5</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.17.5</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -0,0 +1,59 @@
package de.assecutor.votianlt.config;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* Einmalige Migration der vorhandenen Rechnungen auf das neue Lifecycle-Modell.
*
* Bestandsdaten haben weder Status noch Typ. Sie werden konservativ auf
* <code>type=INVOICE, status=ISSUED</code> gesetzt sie sind in der Praxis bereits
* ausgestellt, denn sie tragen eine Rechnungsnummer und enthalten ein PDF.
*
* Ein Audit-Eintrag dokumentiert die Migration (R-37).
*/
@Component
@Order(50)
public class InvoiceLifecycleMigration implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleMigration.class);
private final CustomerInvoiceRepository invoiceRepository;
public InvoiceLifecycleMigration(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Override
public void run(String... args) {
List<CustomerInvoice> legacyInvoices = invoiceRepository.findByStatusIsNull();
if (legacyInvoices.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
for (CustomerInvoice invoice : legacyInvoices) {
invoice.setType(InvoiceType.INVOICE);
invoice.setStatus(InvoiceStatus.ISSUED);
if (invoice.getIssuedAt() == null) {
invoice.setIssuedAt(now);
}
InvoiceAuditEntry entry = new InvoiceAuditEntry(InvoiceAuditAction.ISSUED, null, "system",
"Migration: Bestandsrechnung auf Status ISSUED gesetzt.");
invoice.addAuditEntry(entry);
}
invoiceRepository.saveAll(legacyInvoices);
log.info("Lifecycle-Migration: {} Bestandsrechnungen auf Status ISSUED gesetzt.", legacyInvoices.size());
}
}

View File

@@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Document(collection = "customerInvoices") @Document(collection = "customerInvoices")
@@ -12,6 +14,33 @@ public class CustomerInvoice {
@Id @Id
private String id; private String id;
// Lebenszyklus und Belegtyp gemäß invoices_rules R-01 bis R-22
private InvoiceStatus status = InvoiceStatus.DRAFT;
private InvoiceType type = InvoiceType.INVOICE;
// Verknüpfung auf die Originalrechnung bei Korrektur- oder Stornobelegen (R-10, R-13, R-19, R-22, R-30)
private String originalInvoiceId;
private String originalInvoiceNumber;
private LocalDate originalInvoiceDate;
// Verkettung: bei stornierten/korrigierten Originalen Verweise auf die erzeugten Folgebelege.
private String cancellationInvoiceId;
private String correctionInvoiceId;
private String replacementInvoiceId;
// Zeitstempel für Statusübergänge
private LocalDateTime issuedAt;
private LocalDateTime sentAt;
private LocalDateTime cancelledAt;
// Änderungsprotokoll (R-36 bis R-39)
private List<InvoiceAuditEntry> auditLog = new ArrayList<>();
// Zahlungsstatus gemäß R-23 bis R-26
private PaymentStatus paymentStatus = PaymentStatus.UNPAID;
private BigDecimal paidAmount;
private LocalDateTime lastPaymentAt;
// Pflichtangaben nach §14 UStG (German VAT law) // Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum private LocalDate invoiceDate; // Rechnungsdatum
@@ -372,4 +401,134 @@ public class CustomerInvoice {
public void setPdfData(byte[] pdfData) { public void setPdfData(byte[] pdfData) {
this.pdfData = pdfData; this.pdfData = pdfData;
} }
public InvoiceStatus getStatus() {
return status;
}
public void setStatus(InvoiceStatus status) {
this.status = status;
}
public InvoiceType getType() {
return type;
}
public void setType(InvoiceType type) {
this.type = type;
}
public String getOriginalInvoiceId() {
return originalInvoiceId;
}
public void setOriginalInvoiceId(String originalInvoiceId) {
this.originalInvoiceId = originalInvoiceId;
}
public String getOriginalInvoiceNumber() {
return originalInvoiceNumber;
}
public void setOriginalInvoiceNumber(String originalInvoiceNumber) {
this.originalInvoiceNumber = originalInvoiceNumber;
}
public LocalDate getOriginalInvoiceDate() {
return originalInvoiceDate;
}
public void setOriginalInvoiceDate(LocalDate originalInvoiceDate) {
this.originalInvoiceDate = originalInvoiceDate;
}
public String getCancellationInvoiceId() {
return cancellationInvoiceId;
}
public void setCancellationInvoiceId(String cancellationInvoiceId) {
this.cancellationInvoiceId = cancellationInvoiceId;
}
public String getCorrectionInvoiceId() {
return correctionInvoiceId;
}
public void setCorrectionInvoiceId(String correctionInvoiceId) {
this.correctionInvoiceId = correctionInvoiceId;
}
public String getReplacementInvoiceId() {
return replacementInvoiceId;
}
public void setReplacementInvoiceId(String replacementInvoiceId) {
this.replacementInvoiceId = replacementInvoiceId;
}
public LocalDateTime getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(LocalDateTime issuedAt) {
this.issuedAt = issuedAt;
}
public LocalDateTime getSentAt() {
return sentAt;
}
public void setSentAt(LocalDateTime sentAt) {
this.sentAt = sentAt;
}
public LocalDateTime getCancelledAt() {
return cancelledAt;
}
public void setCancelledAt(LocalDateTime cancelledAt) {
this.cancelledAt = cancelledAt;
}
public List<InvoiceAuditEntry> getAuditLog() {
if (auditLog == null) {
auditLog = new ArrayList<>();
}
return auditLog;
}
public void setAuditLog(List<InvoiceAuditEntry> auditLog) {
this.auditLog = auditLog != null ? auditLog : new ArrayList<>();
}
public void addAuditEntry(InvoiceAuditEntry entry) {
if (entry == null) {
return;
}
getAuditLog().add(entry);
}
public PaymentStatus getPaymentStatus() {
return paymentStatus;
}
public void setPaymentStatus(PaymentStatus paymentStatus) {
this.paymentStatus = paymentStatus;
}
public BigDecimal getPaidAmount() {
return paidAmount;
}
public void setPaidAmount(BigDecimal paidAmount) {
this.paidAmount = paidAmount;
}
public LocalDateTime getLastPaymentAt() {
return lastPaymentAt;
}
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
this.lastPaymentAt = lastPaymentAt;
}
} }

View File

@@ -0,0 +1,8 @@
package de.assecutor.votianlt.model.invoices;
/**
* Aktionen, die im Rechnungs-Audit-Log gemäß R-36 protokolliert werden.
*/
public enum InvoiceAuditAction {
CREATED_DRAFT, UPDATED_DRAFT, ISSUED, SENT, CANCELLED, CORRECTED, REPLACED, DELETED_DRAFT, PAYMENT_RECORDED
}

View File

@@ -0,0 +1,89 @@
package de.assecutor.votianlt.model.invoices;
import java.time.LocalDateTime;
/**
* Einzelner Eintrag im Änderungsprotokoll einer Rechnung gemäß R-36 bis R-39.
*
* Eingebettet in {@link CustomerInvoice}; wird ausschließlich angehängt, niemals
* geändert. Hält Wer/Wann/Was/Warum sowie ggf. den erzeugten Folgebeleg fest.
*/
public class InvoiceAuditEntry {
private LocalDateTime timestamp;
private String userId;
private String userDisplayName;
private InvoiceAuditAction action;
private String reason;
/** Optionale Referenz auf einen erzeugten Folgebeleg (Korrektur, Storno, Ersatzrechnung). */
private String resultingInvoiceId;
private String resultingInvoiceNumber;
public InvoiceAuditEntry() {
}
public InvoiceAuditEntry(InvoiceAuditAction action, String userId, String userDisplayName, String reason) {
this.timestamp = LocalDateTime.now();
this.action = action;
this.userId = userId;
this.userDisplayName = userDisplayName;
this.reason = reason;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserDisplayName() {
return userDisplayName;
}
public void setUserDisplayName(String userDisplayName) {
this.userDisplayName = userDisplayName;
}
public InvoiceAuditAction getAction() {
return action;
}
public void setAction(InvoiceAuditAction action) {
this.action = action;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getResultingInvoiceId() {
return resultingInvoiceId;
}
public void setResultingInvoiceId(String resultingInvoiceId) {
this.resultingInvoiceId = resultingInvoiceId;
}
public String getResultingInvoiceNumber() {
return resultingInvoiceNumber;
}
public void setResultingInvoiceNumber(String resultingInvoiceNumber) {
this.resultingInvoiceNumber = resultingInvoiceNumber;
}
}

View File

@@ -0,0 +1,61 @@
package de.assecutor.votianlt.model.invoices;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.CompoundIndexes;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
/**
* Audit-Eintrag für jede aus dem Rechnungsnummern-Counter gezogene Nummer.
* Erlaubt nachzuweisen, dass jede Lücke im fortlaufenden Nummernkreis erklärt
* werden kann (vergeben aber nicht ausgestellt → entweder offen oder begründet
* verworfen). Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG i.V.m. GoBD.
*
* Pro (userId, sequence) existiert genau eine Reservierung — ein Eindeutigkeits-
* Index erzwingt das auch bei nebenläufigen Aufrufen.
*/
@Data
@Document(collection = "invoice_number_reservations")
@CompoundIndexes({
@CompoundIndex(name = "user_sequence_unique", def = "{'userId': 1, 'sequence': 1}", unique = true),
@CompoundIndex(name = "user_status", def = "{'userId': 1, 'status': 1}")
})
public class InvoiceNumberReservation {
@Id
private ObjectId id;
@Indexed
private ObjectId userId;
/** Vollständige formatierte Rechnungsnummer wie sie auf dem Beleg erscheint (Präfix + Sequenz). */
private String number;
/** Roh-Sequenznummer aus dem Counter — Basis für Lücken-Analyse. */
private long sequence;
/** Präfix, mit dem die Nummer formatiert wurde — relevant, falls der Anwender den Präfix später ändert. */
private String prefix;
private Instant reservedAt;
/** Anzeigename des reservierenden Nutzers (z.B. „Anna Müller") oder „system" für Hintergrundprozesse. */
private String reservedBy;
private InvoiceNumberReservationStatus status;
/** Bei status=USED: ID der ausgestellten Rechnung. */
private String invoiceId;
private Instant usedAt;
/** Bei status=VOIDED: vom Anwender erfasster Grund — Pflichtfeld für betriebsprüfungstaugliche Erklärung. */
private String voidReason;
private Instant voidedAt;
}

View File

@@ -0,0 +1,17 @@
package de.assecutor.votianlt.model.invoices;
/**
* Status einer aus dem Nummernkreis gezogenen Rechnungsnummer.
*
* RESERVED Nummer wurde aus dem Counter gezogen, aber noch keine Rechnung dazu festgeschrieben.
* Bleibt eine Reservierung lange in diesem Zustand, deutet das auf einen
* abgebrochenen Erstell-Prozess hin und produziert eine erklärungsbedürftige
* Lücke im Nummernkreis (§ 14 Abs. 4 Nr. 4 UStG).
* USED Eine festgeschriebene Rechnung trägt diese Nummer; lücken-unkritisch.
* VOIDED Reservierung wurde bewusst verworfen (Erstellprozess abgebrochen, Anwender
* hat erklärt, warum die Nummer nicht ausgestellt wurde) — Lücke ist
* dokumentiert und betriebsprüfungstauglich.
*/
public enum InvoiceNumberReservationStatus {
RESERVED, USED, VOIDED
}

View File

@@ -0,0 +1,22 @@
package de.assecutor.votianlt.model.invoices;
/**
* Lebenszyklus einer Rechnung gemäß R-01 bis R-04.
*
* DRAFT noch in Bearbeitung, darf editiert oder gelöscht werden.
* ISSUED formal ausgestellt/gebucht, darf nicht mehr direkt überschrieben werden.
* SENT an den Empfänger versendet.
* CANCELLED durch eine Stornorechnung aufgehoben.
* CORRECTED durch eine Berichtigung formal korrigiert (Original bleibt sichtbar).
*/
public enum InvoiceStatus {
DRAFT, ISSUED, SENT, CANCELLED, CORRECTED;
public boolean isFinalized() {
return this != DRAFT;
}
public boolean isMutable() {
return this == DRAFT;
}
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.model.invoices;
/**
* Belegtyp einer Rechnung gemäß R-09, R-12 ff. und R-17 ff.
*
* INVOICE reguläre Ausgangsrechnung.
* CORRECTION Rechnungsberichtigung für formale Fehler (R-12 bis R-16).
* Verweist eindeutig auf die zu korrigierende Originalrechnung.
* CANCELLATION Stornorechnung für wirtschaftliche Fehler (R-17 bis R-22).
* Verweist eindeutig auf die zu stornierende Originalrechnung.
*/
public enum InvoiceType {
INVOICE, CORRECTION, CANCELLATION
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.model.invoices;
/**
* Zahlungsstatus einer Rechnung gemäß R-23 bis R-26.
*
* UNPAID noch nicht bezahlt.
* PARTIALLY_PAID Teilzahlung erhalten, Restbetrag offen.
* PAID vollständig bezahlt.
* OVERPAID Zahlbetrag übersteigt den Rechnungsbetrag.
* REFUND_DUE Erstattungsbetrag offen (z.B. nach Storno einer bezahlten Rechnung).
*/
public enum PaymentStatus {
UNPAID, PARTIALLY_PAID, PAID, OVERPAID, REFUND_DUE
}

View File

@@ -0,0 +1,56 @@
package de.assecutor.votianlt.pages.base.ui.component;
import de.assecutor.votianlt.model.Customer;
import java.util.Map;
public final class CustomerAddressLabelHelper {
private CustomerAddressLabelHelper() {
}
public static void putUnique(Map<String, Customer> target, Customer customer, String fallbackLabel) {
String label = build(customer, fallbackLabel);
String uniqueLabel = label;
int counter = 2;
while (target.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
target.put(uniqueLabel, customer);
}
public static String build(Customer customer, String fallbackLabel) {
if (customer == null) {
return fallbackLabel;
}
String companyName = trimToNull(customer.getCompanyName());
if (companyName != null) {
return companyName;
}
String fullName = trimToNull(join(" ", customer.getFirstname(), customer.getLastName()));
return fullName != null ? fullName : fallbackLabel;
}
public static String resolveCompanyValue(Map<String, Customer> addressOptions, String comboValue) {
if (addressOptions.containsKey(comboValue)) {
Customer customer = addressOptions.get(comboValue);
return customer != null ? customer.getCompanyName() : null;
}
return comboValue;
}
private static String join(String separator, String first, String second) {
String left = first != null ? first.trim() : "";
String right = second != null ? second.trim() : "";
return (left + separator + right).trim();
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -254,9 +254,9 @@ public class DeliveryStationDialog extends Dialog {
formLayout.setSpacing(true); formLayout.setSpacing(true);
formLayout.setWidthFull(); formLayout.setWidthFull();
// Company with autocomplete // Delivery address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company")); company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
company.setAllowCustomValue(true); company.setAllowCustomValue(true);
company.setWidthFull(); company.setWidthFull();
setupCompanyAutocomplete(company, customers); setupCompanyAutocomplete(company, customers);
@@ -390,7 +390,7 @@ public class DeliveryStationDialog extends Dialog {
addressTabError = createTabErrorIndicator(); addressTabError = createTabErrorIndicator();
tasksTabError = createTabErrorIndicator(); tasksTabError = createTabErrorIndicator();
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout); Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.delivery.address"), formLayout);
addressTab.add(addressTabError); addressTab.add(addressTabError);
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"), Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
createTasksTab(templates, templateSaveCallback)); createTasksTab(templates, templateSaveCallback));
@@ -687,17 +687,8 @@ public class DeliveryStationDialog extends Dialog {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) { private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
companyAddressOptions.clear(); companyAddressOptions.clear();
for (Customer customer : customers) { for (Customer customer : customers) {
String label = buildCompanyAddressLabel(customer); CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
if (label == null) { translationHelper.getTranslation("addjob.customer.unnamed"));
continue;
}
String uniqueLabel = label;
int counter = 2;
while (companyAddressOptions.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
companyAddressOptions.put(uniqueLabel, customer);
} }
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet())); companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
@@ -769,51 +760,8 @@ public class DeliveryStationDialog extends Dialog {
mail.setRequiredIndicatorVisible(false); mail.setRequiredIndicatorVisible(false);
} }
private String buildCompanyAddressLabel(Customer customer) {
if (customer == null) {
return null;
}
List<String> leftParts = new ArrayList<>();
if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
leftParts.add(customer.getCompanyName().trim());
}
String fullName = ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim();
if (!fullName.isBlank()) {
leftParts.add(fullName);
}
List<String> rightParts = new ArrayList<>();
String streetLine = ((customer.getStreet() != null ? customer.getStreet() : "") + " "
+ (customer.getHouseNumber() != null ? customer.getHouseNumber() : "")).trim();
if (!streetLine.isBlank()) {
rightParts.add(streetLine);
}
String cityLine = ((customer.getZip() != null ? customer.getZip() : "") + " "
+ (customer.getCity() != null ? customer.getCity() : "")).trim();
if (!cityLine.isBlank()) {
rightParts.add(cityLine);
}
String left = String.join(" | ", leftParts);
String right = String.join(", ", rightParts);
String label = left;
if (!right.isBlank()) {
label = label.isBlank() ? right : left + " | " + right;
}
return label.isBlank() ? null : label;
}
private String resolveCompanyValue(String comboValue) { private String resolveCompanyValue(String comboValue) {
Customer customer = companyAddressOptions.get(comboValue); return CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, comboValue);
if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
return customer.getCompanyName();
}
return comboValue;
} }
private String findCompanyOptionLabel(DeliveryData data) { private String findCompanyOptionLabel(DeliveryData data) {

View File

@@ -16,8 +16,10 @@ import com.vaadin.flow.component.textfield.TextField;
import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.DeliveryStation;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Map;
/** /**
* A self-contained tile for one delivery station in the AddJob form. Contains * A self-contained tile for one delivery station in the AddJob form. Contains
@@ -51,6 +53,7 @@ public class DeliveryStationTile extends VerticalLayout {
private final TextField city; private final TextField city;
private final Checkbox saveAddress; private final Checkbox saveAddress;
private final H3 title; private final H3 title;
private final Map<String, Customer> companyAddressOptions = new LinkedHashMap<>();
private ChangeListener changeListener; private ChangeListener changeListener;
private DeleteListener deleteListener; private DeleteListener deleteListener;
@@ -100,9 +103,9 @@ public class DeliveryStationTile extends VerticalLayout {
add(titleLayout); add(titleLayout);
// Company with autocomplete // Delivery address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company")); company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
company.setAllowCustomValue(true); company.setAllowCustomValue(true);
company.setWidthFull(); company.setWidthFull();
setupCompanyAutocomplete(company, customers, translationHelper); setupCompanyAutocomplete(company, customers, translationHelper);
@@ -224,22 +227,22 @@ public class DeliveryStationTile extends VerticalLayout {
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers, private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
TranslationHelper translationHelper) { TranslationHelper translationHelper) {
List<String> companyNames = customers.stream().map(Customer::getCompanyName) companyAddressOptions.clear();
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); for (Customer customer : customers) {
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
companyField.setItems(companyNames); companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedAddress = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
return; return;
} }
Optional<Customer> matchingCustomer = customers.stream() Customer customer = companyAddressOptions.get(selectedAddress);
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst(); if (customer != null) {
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
if (customer.getTitle() != null if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle()) && ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) { || "Divers".equalsIgnoreCase(customer.getTitle()))) {
@@ -282,7 +285,7 @@ public class DeliveryStationTile extends VerticalLayout {
*/ */
public DeliveryStation getDeliveryStation() { public DeliveryStation getDeliveryStation() {
DeliveryStation station = new DeliveryStation(); DeliveryStation station = new DeliveryStation();
station.setCompany(company.getValue()); station.setCompany(CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, company.getValue()));
station.setSalutation(salutation.getValue()); station.setSalutation(salutation.getValue());
station.setFirstName(firstName.getValue()); station.setFirstName(firstName.getValue());
station.setLastName(lastName.getValue()); station.setLastName(lastName.getValue());

View File

@@ -35,7 +35,6 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
@@ -303,7 +302,7 @@ public class PickupStationDialog extends Dialog {
formLayout.setSpacing(true); formLayout.setSpacing(true);
formLayout.setWidthFull(); formLayout.setWidthFull();
// Customer selection // Principal selection
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label")); customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder")); customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
customerComboBox.setRequiredIndicatorVisible(true); customerComboBox.setRequiredIndicatorVisible(true);
@@ -311,27 +310,14 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear(); customerLabelMap.clear();
for (Customer c : customers) { for (Customer c : customers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank()) CustomerAddressLabelHelper.putUnique(customerLabelMap, c,
? c.getCompanyName() + " | " translationHelper.getTranslation("addjob.customer.unnamed"));
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
if (label.isBlank()) {
label = translationHelper.getTranslation("addjob.customer.unnamed");
}
String uniqueLabel = label;
int counter = 2;
while (customerLabelMap.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
customerLabelMap.put(uniqueLabel, c);
} }
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet())); customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
// Company with autocomplete // Pickup address with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company")); company = new ComboBox<>(translationHelper.getTranslation("addjob.address.pickup.label"));
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder")); company.setPlaceholder(translationHelper.getTranslation("addjob.address.pickup.placeholder"));
company.setAllowCustomValue(true); company.setAllowCustomValue(true);
company.setWidthFull(); company.setWidthFull();
setupCompanyAutocomplete(company, customers); setupCompanyAutocomplete(company, customers);
@@ -462,10 +448,7 @@ public class PickupStationDialog extends Dialog {
return; return;
} }
selectedCustomerId = c.getId(); selectedCustomerId = c.getId();
if (c.getCompanyName() != null) setCompanySelection(c);
company.setValue(c.getCompanyName());
else
company.clear();
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle()) if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|| "Divers".equalsIgnoreCase(c.getTitle()))) || "Divers".equalsIgnoreCase(c.getTitle())))
salutation.setValue(c.getTitle()); salutation.setValue(c.getTitle());
@@ -511,7 +494,12 @@ public class PickupStationDialog extends Dialog {
updateSaveAddressState(); updateSaveAddressState();
}); });
formLayout.add(customerComboBox, company, salutation, firstName, lastName, phone, mail, streetLayout, Div addressDivider = new Div();
addressDivider.setWidthFull();
addressDivider.getStyle().set("border-top", "1px solid var(--lumo-contrast-20pct)");
addressDivider.getStyle().set("margin", "var(--lumo-space-m) 0 var(--lumo-space-s)");
formLayout.add(customerComboBox, addressDivider, company, salutation, firstName, lastName, phone, mail, streetLayout,
addressAddition, zipCityLayout, saveAddress); addressAddition, zipCityLayout, saveAddress);
// TabSheet with address, appointments, and cargo tabs // TabSheet with address, appointments, and cargo tabs
@@ -523,7 +511,7 @@ public class PickupStationDialog extends Dialog {
appointmentsTabError = createTabErrorIndicator(); appointmentsTabError = createTabErrorIndicator();
cargoTabError = createTabErrorIndicator(); cargoTabError = createTabErrorIndicator();
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout); Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.pickup.address"), formLayout);
addressTab.add(addressTabError); addressTab.add(addressTabError);
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"), Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
createAppointmentsTab(availableAppUsers)); createAppointmentsTab(availableAppUsers));
@@ -637,13 +625,23 @@ public class PickupStationDialog extends Dialog {
public void setData(PickupData data) { public void setData(PickupData data) {
if (data == null) if (data == null)
return; return;
if (data.getCustomerSelection() != null) { String customerSelection = normalizeValue(data.getCustomerSelection());
customerComboBox.setValue(data.getCustomerSelection()); if (!customerSelection.isEmpty()) {
if (!customerLabelMap.containsKey(customerSelection)) {
customerLabelMap.put(customerSelection, null);
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
}
customerComboBox.setValue(customerSelection);
} else { } else {
customerComboBox.clear(); customerComboBox.clear();
} }
if (data.getCompany() != null) String companyOption = findCompanyOptionLabel(data);
boolean customerSelectedFromOptions = companyOption != null;
if (companyOption != null) {
company.setValue(companyOption);
} else if (data.getCompany() != null) {
company.setValue(data.getCompany()); company.setValue(data.getCompany());
}
if (data.getSalutation() != null) if (data.getSalutation() != null)
salutation.setValue(data.getSalutation()); salutation.setValue(data.getSalutation());
if (data.getFirstName() != null) if (data.getFirstName() != null)
@@ -689,19 +687,16 @@ public class PickupStationDialog extends Dialog {
if (data.getCustomerId() != null) { if (data.getCustomerId() != null) {
selectedCustomerId = data.getCustomerId(); selectedCustomerId = data.getCustomerId();
} else { } else {
Customer matched = customerLabelMap.get(data.getCustomerSelection()); Customer matched = companyOption != null ? companyCustomerMap.get(companyOption) : null;
if (matched == null) {
matched = companyCustomerMap.get(normalizeValue(data.getCompany()));
}
selectedCustomerId = matched != null ? matched.getId() : null; selectedCustomerId = matched != null ? matched.getId() : null;
} }
saveAddress.setValue(data.isSaveAddress()); saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
updateSaveAddressState(); updateSaveAddressState();
} }
private PickupData collectData() { private PickupData collectData() {
PickupData data = new PickupData(); PickupData data = new PickupData();
data.setCompany(company.getValue()); data.setCompany(resolveCompanyValue(company.getValue()));
data.setSalutation(salutation.getValue()); data.setSalutation(salutation.getValue());
data.setFirstName(firstName.getValue()); data.setFirstName(firstName.getValue());
data.setLastName(lastName.getValue()); data.setLastName(lastName.getValue());
@@ -781,12 +776,9 @@ public class PickupStationDialog extends Dialog {
private boolean validateMailField() { private boolean validateMailField() {
String value = mail.getValue(); String value = mail.getValue();
String normalizedValue = value == null ? "" : value.trim(); String normalizedValue = value == null ? "" : value.trim();
boolean empty = normalizedValue.isEmpty(); boolean invalid = !normalizedValue.isEmpty() && !normalizedValue.contains("@");
boolean required = Boolean.TRUE.equals(saveAddress.getValue()); applyErrorStyling(mail, invalid);
boolean invalid = !empty && !normalizedValue.contains("@"); return !invalid;
boolean hasError = invalid || (required && empty);
applyErrorStyling(mail, hasError);
return !hasError;
} }
private boolean validateCargoItems() { private boolean validateCargoItems() {
@@ -838,56 +830,24 @@ public class PickupStationDialog extends Dialog {
} }
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) { private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
companyCustomerMap.clear(); companyCustomerMap.clear();
for (Customer customer : customers) { for (Customer customer : customers) {
String companyName = normalizeValue(customer.getCompanyName()); CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer,
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) { translationHelper.getTranslation("addjob.customer.unnamed"));
continue;
}
companyCustomerMap.put(companyName, customer);
} }
companyField.setItems(companyNames); companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedAddress = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
selectedCustomerId = null; selectedCustomerId = null;
updateSaveAddressState(); updateSaveAddressState();
return; return;
} }
Optional<Customer> matchingCustomer = customers.stream() Customer customer = companyCustomerMap.get(selectedAddress);
.filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst(); if (customer != null) {
applyCustomerAddress(customer);
if (matchingCustomer.isPresent()) {
Customer customer = matchingCustomer.get();
selectedCustomerId = customer.getId();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
salutation.setValue(customer.getTitle());
}
if (customer.getFirstname() != null)
firstName.setValue(customer.getFirstname());
if (customer.getLastName() != null)
lastName.setValue(customer.getLastName());
if (customer.getTelephone() != null)
phone.setValue(customer.getTelephone());
if (customer.getMail() != null)
mail.setValue(customer.getMail());
if (customer.getStreet() != null)
street.setValue(customer.getStreet());
if (customer.getHouseNumber() != null)
houseNumber.setValue(customer.getHouseNumber());
if (customer.getAddressAddition() != null)
addressAddition.setValue(customer.getAddressAddition());
if (customer.getZip() != null)
zip.setValue(customer.getZip());
if (customer.getCity() != null)
city.setValue(customer.getCity());
} }
updateSaveAddressState(); updateSaveAddressState();
@@ -902,7 +862,7 @@ public class PickupStationDialog extends Dialog {
private void updateSaveAddressState() { private void updateSaveAddressState() {
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue()); Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue())); Customer selectedCompanyCustomer = companyCustomerMap.get(company.getValue());
boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer); boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer); boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
@@ -924,11 +884,11 @@ public class PickupStationDialog extends Dialog {
} }
private void updateMailRequirement() { private void updateMailRequirement() {
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue())); mail.setRequiredIndicatorVisible(false);
} }
private boolean matchesCustomer(Customer customer) { private boolean matchesCustomer(Customer customer) {
return sameValue(company.getValue(), customer.getCompanyName()) return sameValue(resolveCompanyValue(company.getValue()), customer.getCompanyName())
&& sameValue(salutation.getValue(), customer.getTitle()) && sameValue(salutation.getValue(), customer.getTitle())
&& sameValue(firstName.getValue(), customer.getFirstname()) && sameValue(firstName.getValue(), customer.getFirstname())
&& sameValue(lastName.getValue(), customer.getLastName()) && sameValue(lastName.getValue(), customer.getLastName())
@@ -950,7 +910,7 @@ public class PickupStationDialog extends Dialog {
} }
private boolean computeAddressDiffers() { private boolean computeAddressDiffers() {
boolean hasAnyValue = !normalizeValue(company.getValue()).isEmpty() boolean hasAnyValue = !normalizeValue(resolveCompanyValue(company.getValue())).isEmpty()
|| !normalizeValue(firstName.getValue()).isEmpty() || !normalizeValue(lastName.getValue()).isEmpty() || !normalizeValue(firstName.getValue()).isEmpty() || !normalizeValue(lastName.getValue()).isEmpty()
|| !normalizeValue(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty() || !normalizeValue(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty()
|| !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty() || !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty()
@@ -983,6 +943,108 @@ public class PickupStationDialog extends Dialog {
return null; return null;
} }
private void applyCustomerAddress(Customer customer) {
selectedCustomerId = customer.getId();
if (customer.getTitle() != null
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
salutation.setValue(customer.getTitle());
} else {
salutation.clear();
}
if (customer.getFirstname() != null)
firstName.setValue(customer.getFirstname());
else
firstName.clear();
if (customer.getLastName() != null)
lastName.setValue(customer.getLastName());
else
lastName.clear();
if (customer.getTelephone() != null)
phone.setValue(customer.getTelephone());
else
phone.clear();
if (customer.getMail() != null)
mail.setValue(customer.getMail());
else
mail.clear();
if (customer.getStreet() != null)
street.setValue(customer.getStreet());
else
street.clear();
if (customer.getHouseNumber() != null)
houseNumber.setValue(customer.getHouseNumber());
else
houseNumber.clear();
if (customer.getAddressAddition() != null)
addressAddition.setValue(customer.getAddressAddition());
else
addressAddition.clear();
if (customer.getZip() != null)
zip.setValue(customer.getZip());
else
zip.clear();
if (customer.getCity() != null)
city.setValue(customer.getCity());
else
city.clear();
}
private void setCompanySelection(Customer customer) {
String label = findCompanyOptionLabel(customer);
if (label != null) {
company.setValue(label);
} else if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
company.setValue(customer.getCompanyName());
} else {
company.clear();
}
}
private String resolveCompanyValue(String comboValue) {
return CustomerAddressLabelHelper.resolveCompanyValue(companyCustomerMap, comboValue);
}
private String findCompanyOptionLabel(Customer customer) {
if (customer == null || customer.getId() == null) {
return null;
}
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
Customer option = entry.getValue();
if (option != null && customer.getId().equals(option.getId())) {
return entry.getKey();
}
}
return null;
}
private String findCompanyOptionLabel(PickupData data) {
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
Customer customer = entry.getValue();
if (data.getCustomerId() != null && customer.getId() != null && data.getCustomerId().equals(customer.getId())) {
return entry.getKey();
}
if (matchesCustomer(customer, data)) {
return entry.getKey();
}
}
return null;
}
private boolean matchesCustomer(Customer customer, PickupData data) {
return sameValue(customer.getCompanyName(), data.getCompany())
&& sameValue(customer.getTitle(), data.getSalutation())
&& sameValue(customer.getFirstname(), data.getFirstName())
&& sameValue(customer.getLastName(), data.getLastName())
&& sameValue(customer.getTelephone(), data.getPhone())
&& sameValue(customer.getMail(), data.getMail())
&& sameValue(customer.getStreet(), data.getStreet())
&& sameValue(customer.getHouseNumber(), data.getHouseNumber())
&& sameValue(customer.getAddressAddition(), data.getAddressAddition())
&& sameValue(customer.getZip(), data.getZip())
&& sameValue(customer.getCity(), data.getCity());
}
// ============================================ // ============================================
// Appointments & Processing Tab // Appointments & Processing Tab
// ============================================ // ============================================

View File

@@ -5,6 +5,7 @@ import org.bson.types.ObjectId;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List; import java.util.List;
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> { public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
@@ -14,5 +15,8 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
List<Customer> findByOwner(ObjectId owner); List<Customer> findByOwner(ObjectId owner);
// $ne: true matches documents where internal is false, null, or the field is missing
// (legacy data without the internal field still shows up in customer dropdowns).
@Query("{ 'owner' : ?0, 'internal' : { $ne: true } }")
List<Customer> findByOwnerAndInternalFalse(ObjectId owner); List<Customer> findByOwnerAndInternalFalse(ObjectId owner);
} }

View File

@@ -43,4 +43,8 @@ public class CustomerService {
return todoRepository.findById(id).orElse(null); return todoRepository.findById(id).orElse(null);
} }
public void deleteById(ObjectId id) {
todoRepository.deleteById(id);
}
} }

View File

@@ -1,8 +1,14 @@
package de.assecutor.votianlt.pages.service; package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.UserInvoiceData; import de.assecutor.votianlt.model.UserInvoiceData;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import de.assecutor.votianlt.repository.UserInvoiceDataRepository; import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Criteria;
@@ -10,17 +16,25 @@ import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class UserInvoiceDataService { public class UserInvoiceDataService {
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
private final UserInvoiceDataRepository userInvoiceDataRepository; private final UserInvoiceDataRepository userInvoiceDataRepository;
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
private final InvoiceNumberReservationRepository reservationRepository;
private final SecurityService securityService;
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate) { public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate,
InvoiceNumberReservationRepository reservationRepository, SecurityService securityService) {
this.userInvoiceDataRepository = userInvoiceDataRepository; this.userInvoiceDataRepository = userInvoiceDataRepository;
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
this.reservationRepository = reservationRepository;
this.securityService = securityService;
} }
public Optional<UserInvoiceData> findByUserId(ObjectId userId) { public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
@@ -64,6 +78,12 @@ public class UserInvoiceDataService {
/** /**
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den * Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer). * Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
*
* Jede Vergabe wird als {@link InvoiceNumberReservation} mit Status RESERVED
* persistiert. Damit ist auch nachvollziehbar, wenn eine Nummer aus dem
* Counter gezogen, aber nie zu einer ausgestellten Rechnung wird (abgebrochener
* Erstell-Prozess, fehlgeschlagene Validierung). Die Reservierung wird später
* vom Lifecycle-Service auf USED bzw. VOIDED gesetzt.
*/ */
public String generateNextInvoiceNumber(ObjectId userId) { public String generateNextInvoiceNumber(ObjectId userId) {
Query query = Query.query(Criteria.where("userId").is(userId)); Query query = Query.query(Criteria.where("userId").is(userId));
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten // Kein Eintrag vorhanden - Fallback auf aktuelle Daten
return findByUserId(userId).map(d -> { return findByUserId(userId).map(d -> {
String prefix = d.getPrefix() != null ? d.getPrefix() : ""; String prefix = d.getPrefix() != null ? d.getPrefix() : "";
return prefix + String.format("%06d", d.getNextInvoiceNumber()); long sequence = d.getNextInvoiceNumber();
String number = prefix + String.format("%06d", sequence);
recordReservation(userId, number, sequence, prefix);
return number;
}).orElse("000000"); }).orElse("000000");
} }
String prefix = before.getPrefix() != null ? before.getPrefix() : ""; String prefix = before.getPrefix() != null ? before.getPrefix() : "";
return prefix + String.format("%06d", before.getNextInvoiceNumber()); long sequence = before.getNextInvoiceNumber();
String number = prefix + String.format("%06d", sequence);
recordReservation(userId, number, sequence, prefix);
return number;
}
/**
* Persistiert die Reservierung einer Nummer. Das Schreiben des Audit-Eintrags
* ist von der Counter-Vergabe entkoppelt: Sollte das Audit-Repository
* vorübergehend ausfallen, geht die Nummer-Vergabe nicht verloren — wir
* loggen den Fehler und vertrauen darauf, dass die anschließende Lücken-
* Analyse auf Basis der ausgestellten Rechnungen die fehlende Reservierung
* sichtbar macht.
*/
private void recordReservation(ObjectId userId, String number, long sequence, String prefix) {
try {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(number);
reservation.setSequence(sequence);
reservation.setPrefix(prefix);
reservation.setReservedAt(Instant.now());
reservation.setReservedBy(currentUserDisplayName());
reservation.setStatus(InvoiceNumberReservationStatus.RESERVED);
reservationRepository.save(reservation);
} catch (Exception ex) {
log.warn("Reservierung der Rechnungsnummer {} (User {}) konnte nicht persistiert werden: {}",
number, userId, ex.getMessage(), ex);
}
}
private String currentUserDisplayName() {
try {
var user = securityService.getCurrentDatabaseUser();
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
return composed.isBlank() ? safe(user.getEmail()) : composed;
} catch (Exception ignored) {
return "system";
}
}
private String safe(String value) {
return value != null ? value : "";
} }
} }

View File

@@ -46,11 +46,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
public AddCustomerView(AddCustomerService todoService, Clock clock) { public AddCustomerView(AddCustomerService todoService, Clock clock) {
this.addCustomerService = todoService; this.addCustomerService = todoService;
// Firma (Pflichtfeld) // Firma (optional; auch Privatpersonen können im Adressbuch stehen)
companyName = new TextField(getTranslation("profile.company")); companyName = new TextField(getTranslation("profile.company"));
companyName.setRequiredIndicatorVisible(true);
companyName.setWidthFull(); companyName.setWidthFull();
companyName.addBlurListener(e -> validateField(companyName));
// Anrede (Dropdown) // Anrede (Dropdown)
title = new ComboBox<>(getTranslation("addjob.address.salutation")); title = new ComboBox<>(getTranslation("addjob.address.salutation"));
@@ -162,8 +160,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
} }
private void configureBinder() { private void configureBinder() {
binder.forField(companyName).asRequired(getTranslation("profile.validation.company.required")) binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
.bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(title).bind(Customer::getTitle, Customer::setTitle); binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
@@ -257,7 +254,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
} }
private boolean validateAllFields() { private boolean validateAllFields() {
validateField(companyName);
validateField(firstName); validateField(firstName);
validateField(lastName); validateField(lastName);
validateField(telephone); validateField(telephone);
@@ -267,9 +263,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
validateField(city); validateField(city);
validateEmail(); validateEmail();
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() return !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid()
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
&& !city.isInvalid();
} }
@Override @Override

View File

@@ -63,6 +63,7 @@ import de.assecutor.votianlt.model.AddressValidationResult;
import de.assecutor.votianlt.model.RouteCalculationResult; import de.assecutor.votianlt.model.RouteCalculationResult;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile; import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
import de.assecutor.votianlt.pages.base.ui.component.StationTile; import de.assecutor.votianlt.pages.base.ui.component.StationTile;
import de.assecutor.votianlt.pages.base.ui.component.CustomerAddressLabelHelper;
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog; import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog; import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
@@ -236,32 +237,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder")); customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
customerSelection.setWidthFull(); customerSelection.setWidthFull();
customerSelection.setRequiredIndicatorVisible(true); customerSelection.setRequiredIndicatorVisible(true);
customerSelection.setAllowCustomValue(true);
customerSelection.addCustomValueSetListener(event -> setCustomerSelectionValue(event.getDetail()));
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen // Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner(); List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
customerLabelToEntity.clear(); customerLabelToEntity.clear();
for (Customer c : ownerCustomers) { for (Customer c : ownerCustomers) {
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank()) CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
? c.getCompanyName() + " | "
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
if (label.isBlank()) {
label = getTranslation("addjob.customer.unnamed");
}
// Bei Duplikaten Label einzigartig machen
String uniqueLabel = label;
int counter = 2;
while (customerLabelToEntity.containsKey(uniqueLabel)) {
uniqueLabel = label + " (" + counter++ + ")";
}
customerLabelToEntity.put(uniqueLabel, c);
} }
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet())); customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
// Pickup address // Pickup address
pickupCompany = new ComboBox<>(getTranslation("profile.company")); pickupCompany = new ComboBox<>(getTranslation("addjob.address.pickup.label"));
pickupCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder")); pickupCompany.setPlaceholder(getTranslation("addjob.address.pickup.placeholder"));
pickupCompany.setAllowCustomValue(true); pickupCompany.setAllowCustomValue(true);
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation")); pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
@@ -857,7 +845,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
translationHelper, data -> { translationHelper, data -> {
// Update customer selection from dialog // Update customer selection from dialog
if (data.getCustomerSelection() != null) { if (data.getCustomerSelection() != null) {
customerSelection.setValue(data.getCustomerSelection()); setCustomerSelectionValue(data.getCustomerSelection());
} else { } else {
customerSelection.clear(); customerSelection.clear();
} }
@@ -1116,6 +1104,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
return trimmed.isEmpty() ? null : trimmed; return trimmed.isEmpty() ? null : trimmed;
} }
private void setCustomerSelectionValue(String value) {
String normalizedValue = trimToNull(value);
if (normalizedValue == null) {
customerSelection.clear();
return;
}
if (!customerLabelToEntity.containsKey(normalizedValue)) {
customerLabelToEntity.put(normalizedValue, null);
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
}
customerSelection.setValue(normalizedValue);
}
private void openDeliveryDialog(StationTile tile, int stationIndex) { private void openDeliveryDialog(StationTile tile, int stationIndex) {
// Ensure index is valid (could have changed due to deletions) // Ensure index is valid (could have changed due to deletions)
int actualIndex = deliveryStationTilesList.indexOf(tile); int actualIndex = deliveryStationTilesList.indexOf(tile);
@@ -1412,30 +1413,29 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Get all customers for the current owner // Get all customers for the current owner
List<Customer> allCustomers = customerService.findAllForCurrentOwner(); List<Customer> allCustomers = customerService.findAllForCurrentOwner();
// Extract unique company names (filter out null/empty values) Map<String, Customer> addressOptions = new LinkedHashMap<>();
List<String> companyNames = allCustomers.stream().map(Customer::getCompanyName) for (Customer customer : allCustomers) {
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList(); CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
}
// Set items for autocomplete // Set items for autocomplete
companyField.setItems(companyNames); companyField.setItems(new ArrayList<>(addressOptions.keySet()));
// Add selection listener to auto-fill pickup address fields when company is // Add selection listener to auto-fill pickup address fields when company is
// selected // selected
companyField.addValueChangeListener(event -> { companyField.addValueChangeListener(event -> {
String selectedCompany = event.getValue(); String selectedAddress = event.getValue();
if (selectedCompany == null || selectedCompany.trim().isEmpty()) { if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
return; return;
} }
// Streckeninformationen zurücksetzen, da sich die Adresse ändert // Streckeninformationen zurücksetzen, da sich die Adresse ändert
resetRouteInformation(); resetRouteInformation();
// Find the first customer with this company name Customer customer = addressOptions.get(selectedAddress);
Optional<Customer> matchingCustomer = allCustomers.stream()
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
if (matchingCustomer.isPresent()) { if (customer != null) {
Customer customer = matchingCustomer.get(); pickupCustomerId = customer.getId();
// Fill pickup address fields // Fill pickup address fields
if (customer.getTitle() != null if (customer.getTitle() != null
@@ -1476,6 +1476,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
// Reactivate save checkbox for custom values // Reactivate save checkbox for custom values
savePickupAddress.setValue(true); savePickupAddress.setValue(true);
pickupCustomerId = null;
pickupMail = null; pickupMail = null;
}); });
} }
@@ -1987,7 +1988,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
*/ */
private void loadJobIntoForm(Job job) { private void loadJobIntoForm(Job job) {
if (job.getCustomerSelection() != null) { if (job.getCustomerSelection() != null) {
customerSelection.setValue(job.getCustomerSelection()); setCustomerSelectionValue(job.getCustomerSelection());
} }
} }

View File

@@ -27,12 +27,13 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.InvoiceTemplateService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -62,8 +63,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final SecurityService securityService; private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final CustomerService customerService; private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService;
private User currentUser; private User currentUser;
private Job currentJob; private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>(); private List<ServiceRow> gridRows = new ArrayList<>();
@@ -117,8 +118,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository, public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
UserRepository userRepository, CustomerInvoiceService customerInvoiceService, UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository, UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
CustomerService customerService) { InvoiceLifecycleService invoiceLifecycleService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -126,8 +127,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.securityService = securityService; this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceRepository = customerInvoiceRepository;
this.customerService = customerService; this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);
@@ -584,8 +585,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatRate(vatRate); invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount); invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount); invoice.setTotalAmount(totalAmount);
invoice.setPdfData(pdfBytes); invoice.setPdfData(pdfBytes);
CustomerInvoice savedInvoice = customerInvoiceRepository.save(invoice);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
"Rechnung erstellt aus Auftrag " + currentJob.getJobNumber());
currentJob.setInvoiceId(savedInvoice.getId()); currentJob.setInvoiceId(savedInvoice.getId());
jobRepository.save(currentJob); jobRepository.save(currentJob);
@@ -594,6 +599,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000, Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
Notification.Position.BOTTOM_END); Notification.Position.BOTTOM_END);
} catch (InvoiceLifecycleException lifecycleEx) {
log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage());
Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE);
} catch (Exception ex) { } catch (Exception ex) {
log.error("Fehler beim Speichern der Rechnung", ex); log.error("Fehler beim Speichern der Rechnung", ex);
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000, Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,

View File

@@ -191,6 +191,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> { Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
if (customer != null && customer.getId() != null) { if (customer != null && customer.getId() != null) {
customerService.deleteById(customer.getId());
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000, Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
Notification.Position.MIDDLE); Notification.Position.MIDDLE);
confirmDialog.close(); confirmDialog.close();

View File

@@ -1,39 +1,82 @@
package de.assecutor.votianlt.pages.view; package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid; 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.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.component.UI; import com.vaadin.flow.server.StreamRegistration;
import com.vaadin.flow.server.StreamResource;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository; import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.server.StreamRegistration;
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
public class InvoicesView extends VerticalLayout implements HasDynamicTitle { public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
Locale.GERMANY);
private final Grid<CustomerInvoice> invoiceGrid; private final Grid<CustomerInvoice> invoiceGrid;
private final CustomerInvoiceRepository customerInvoiceRepository; private final CustomerInvoiceRepository customerInvoiceRepository;
private final SecurityService securityService; private final SecurityService securityService;
private final InvoiceLifecycleService invoiceLifecycleService;
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceExportService invoiceExportService;
private final InvoicePermissionService invoicePermissionService;
private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService) { public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) {
this.customerInvoiceRepository = customerInvoiceRepository; this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService; this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
@@ -43,60 +86,502 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
addClassName("data-view"); addClassName("data-view");
add(new ViewToolbar(getTranslation("invoices.title"))); add(new ViewToolbar(getTranslation("invoices.title")));
add(buildLegalDisclaimer());
invoiceGrid = new Grid<>(CustomerInvoice.class, false); invoiceGrid = new Grid<>(CustomerInvoice.class, false);
invoiceGrid.setWidthFull(); invoiceGrid.setWidthFull();
invoiceGrid.addClassName("data-grid"); invoiceGrid.addClassName("data-grid");
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId())) invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderTypeBadge)
.setHeader(getTranslation("invoices.column.type")).setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderStatusBadge)
.setHeader(getTranslation("invoices.column.status")).setAutoWidth(true);
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer")) invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse("")) invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), "")) invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
invoiceGrid.getStyle().set("cursor", "pointer");
invoiceGrid.addItemClickListener(event -> { invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
CustomerInvoice invoice = event.getItem();
if (invoice != null) {
downloadInvoicePdf(invoice);
}
});
loadInvoices();
Div gridPanel = new Div(invoiceGrid); Div gridPanel = new Div(invoiceGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel"); gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull(); gridPanel.setWidthFull();
add(gridPanel); add(gridPanel);
loadInvoices();
}
private Component buildLegalDisclaimer() {
Div banner = new Div();
banner.addClassName("surface-panel");
banner.getStyle().set("padding", "12px 16px").set("border-left", "4px solid var(--lumo-primary-color)")
.set("background", "var(--lumo-contrast-5pct)");
Span text = new Span(getTranslation("invoices.disclaimer"));
text.getStyle().set("font-size", "var(--lumo-font-size-s)");
banner.add(text);
return banner;
} }
private void loadInvoices() { private void loadInvoices() {
String currentUserId = securityService.getCurrentUserId().toHexString(); String currentUserId = securityService.getCurrentUserId().toHexString();
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream() List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
.filter(this::hasPdfData).sorted((left, right) -> { .sorted(Comparator
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) { .comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
return 0; : i.getInvoiceDate())
} .reversed())
if (left.getInvoiceDate() == null) { .toList();
return 1;
}
if (right.getInvoiceDate() == null) {
return -1;
}
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
}).toList();
invoiceGrid.setItems(invoices); invoiceGrid.setItems(invoices);
}
if (invoices.isEmpty()) { private Component renderStatusBadge(CustomerInvoice invoice) {
Span emptyState = new Span(getTranslation("invoices.empty")); InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)"); Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
add(emptyState); badge.getElement().getThemeList().add("badge");
switch (status) {
case DRAFT -> badge.getElement().getThemeList().add("contrast");
case SENT -> badge.getElement().getThemeList().add("success");
case CANCELLED -> badge.getElement().getThemeList().add("error");
case CORRECTED -> badge.getElement().getThemeList().add("warning");
default -> {
} }
}
return badge;
}
private Component renderTypeBadge(CustomerInvoice invoice) {
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
HorizontalLayout layout = new HorizontalLayout();
layout.setSpacing(true);
layout.setPadding(false);
Span badge = new Span(getTranslation("invoices.type." + type.name().toLowerCase(Locale.ROOT)));
badge.getElement().getThemeList().add("badge");
if (type == InvoiceType.CANCELLATION) {
badge.getElement().getThemeList().add("error");
} else if (type == InvoiceType.CORRECTION) {
badge.getElement().getThemeList().add("warning");
}
layout.add(badge);
return layout;
}
private Component renderActions(CustomerInvoice invoice) {
HorizontalLayout actions = new HorizontalLayout();
actions.setSpacing(true);
actions.setPadding(false);
Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice));
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0);
actions.add(viewBtn);
Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice));
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(historyBtn);
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
User currentUser = invoicePermissionService.currentUser();
// Aktionen nur für reguläre, noch aktive Rechnungen anbieten
boolean isLiveInvoice = type == InvoiceType.INVOICE
&& (status == InvoiceStatus.ISSUED || status == InvoiceStatus.SENT);
if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED
&& invoicePermissionService.canMarkAsSent(currentUser)) {
Button sentBtn = new Button(getTranslation("invoices.action.marksent"),
e -> markAsSent(invoice));
sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(sentBtn);
}
if (isLiveInvoice) {
if (invoicePermissionService.canCorrect(currentUser)) {
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
e -> openCorrectionDialog(invoice));
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(correctBtn);
}
if (invoicePermissionService.canCancel(currentUser)) {
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
e -> openCancellationDialog(invoice));
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
actions.add(cancelBtn);
}
}
// Zahlung erfassen: nur für reguläre Rechnungen (R-25)
if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT
&& invoicePermissionService.canRecordPayment(currentUser)) {
Button payBtn = new Button(getTranslation("invoices.action.payment"),
e -> openPaymentDialog(invoice));
payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(payBtn);
}
// Belegpaket exportieren (R-33/R-34)
Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice));
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(exportBtn);
return actions;
}
private void openPaymentDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
Span hint = new Span(getTranslation("invoices.payment.hint",
java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding)));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
NumberField amountField = new NumberField(getTranslation("invoices.payment.amount"));
amountField.setStep(0.01);
amountField.setValue(outstanding.doubleValue());
amountField.setRequiredIndicatorVisible(true);
amountField.setWidthFull();
content.add(amountField);
TextField referenceField = new TextField(getTranslation("invoices.payment.reference"));
referenceField.setWidthFull();
content.add(referenceField);
TextArea reasonField = new TextArea(getTranslation("invoices.payment.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("80px");
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.payment.confirm"), e -> {
Double amount = amountField.getValue();
if (amount == null || amount == 0d) {
amountField.setInvalid(true);
amountField.setErrorMessage(getTranslation("invoices.payment.amount.required"));
return;
}
performPayment(invoice, java.math.BigDecimal.valueOf(amount), referenceField.getValue(),
reasonField.getValue(), dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performPayment(CustomerInvoice invoice, java.math.BigDecimal amount, String reference, String reason,
Dialog dialog) {
try {
invoicePermissionService.requirePayment(invoicePermissionService.currentUser());
invoiceLifecycleService.registerPayment(invoice.getId(), amount, reference, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.payment"), 3000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void exportPackage(CustomerInvoice invoice) {
try {
byte[] zipBytes = invoiceExportService.exportInvoicePackage(invoice);
String fileName = invoiceExportService.suggestFilename(invoice);
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zipBytes));
resource.setContentType("application/zip");
resource.setCacheTime(0);
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
.registerResource(resource);
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void markAsSent(CustomerInvoice invoice) {
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
invoiceLifecycleService.markAsSent(invoice.getId(), "Manuell als versendet markiert");
Notification.show(getTranslation("invoices.notification.sent"), 3000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 5000, Notification.Position.MIDDLE);
}
}
private void openCancellationDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.cancel.title", invoice.getInvoiceNumber()), "560px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
Span hint = new Span(getTranslation("invoices.cancel.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
TextArea reasonField = new TextArea(getTranslation("invoices.cancel.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("100px");
reasonField.setRequired(true);
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.cancel.confirm"), e -> {
String reason = reasonField.getValue();
if (reason == null || reason.isBlank()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("invoices.cancel.reason.required"));
return;
}
performCancellation(invoice, reason, dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performCancellation(CustomerInvoice invoice, String reason, Dialog dialog) {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCancel(currentUser);
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
byte[] pdf = customerInvoiceService.generateCancellationPdf(invoice, number, today, reason);
invoiceLifecycleService.cancel(invoice.getId(), number, today, pdf, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.cancelled", number), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void openCorrectionDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.correct.title", invoice.getInvoiceNumber()), "560px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
Span hint = new Span(getTranslation("invoices.correct.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
TextArea fieldsField = new TextArea(getTranslation("invoices.correct.fields"));
fieldsField.setWidthFull();
fieldsField.setMinHeight("100px");
fieldsField.setHelperText(getTranslation("invoices.correct.fields.helper"));
fieldsField.setRequired(true);
content.add(fieldsField);
TextArea reasonField = new TextArea(getTranslation("invoices.correct.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("80px");
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.correct.confirm"), e -> {
String fields = fieldsField.getValue();
if (fields == null || fields.isBlank()) {
fieldsField.setInvalid(true);
fieldsField.setErrorMessage(getTranslation("invoices.correct.fields.required"));
return;
}
performCorrection(invoice, fields, reasonField.getValue(), dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performCorrection(CustomerInvoice invoice, String correctedFields, String reason, Dialog dialog) {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCorrect(currentUser);
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
byte[] pdf = customerInvoiceService.generateCorrectionPdf(invoice, number, today, reason, correctedFields);
invoiceLifecycleService.correct(invoice.getId(), number, today, pdf, correctedFields, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.corrected", number), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private User resolveIssuer(CustomerInvoice invoice) {
if (invoice.getUserId() != null && !invoice.getUserId().isBlank()) {
try {
return userRepository.findById(new org.bson.types.ObjectId(invoice.getUserId()))
.orElseGet(securityService::getCurrentDatabaseUser);
} catch (IllegalArgumentException ex) {
// userId ist kein gültiger ObjectId Fallback auf eingeloggten Nutzer
}
}
return securityService.getCurrentDatabaseUser();
}
private void openHistoryDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.history.title", invoice.getInvoiceNumber()), "640px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
// Verkettung anzeigen, falls vorhanden
Div linksBlock = renderRelatedInvoiceLinks(invoice);
if (linksBlock != null) {
content.add(linksBlock);
}
H3 logTitle = new H3(getTranslation("invoices.history.log"));
content.add(logTitle);
List<InvoiceAuditEntry> log = invoice.getAuditLog();
if (log == null || log.isEmpty()) {
content.add(new Span(getTranslation("invoices.history.empty")));
} else {
log.stream()
.sorted(Comparator
.comparing((InvoiceAuditEntry e) -> e.getTimestamp() == null
? java.time.LocalDateTime.MIN
: e.getTimestamp())
.reversed())
.forEach(entry -> content.add(renderAuditEntry(entry)));
}
dialog.add(DialogStylingHelper.wrapContent(content, true));
Button closeBtn = new Button(getTranslation("button.close"), e -> dialog.close());
closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dialog.getFooter().add(closeBtn);
dialog.open();
}
private Div renderRelatedInvoiceLinks(CustomerInvoice invoice) {
Div block = new Div();
boolean hasContent = false;
if (invoice.getOriginalInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.original"),
invoice.getOriginalInvoiceNumber(), invoice.getOriginalInvoiceId()));
hasContent = true;
}
if (invoice.getCancellationInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.cancellation"),
null, invoice.getCancellationInvoiceId()));
hasContent = true;
}
if (invoice.getCorrectionInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.correction"),
null, invoice.getCorrectionInvoiceId()));
hasContent = true;
}
if (invoice.getReplacementInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.replacement"),
null, invoice.getReplacementInvoiceId()));
hasContent = true;
}
return hasContent ? block : null;
}
private HorizontalLayout buildLinkRow(String label, String fallbackNumber, String invoiceId) {
HorizontalLayout row = new HorizontalLayout();
row.setSpacing(true);
row.setPadding(false);
Span lbl = new Span(label);
lbl.getStyle().set("min-width", "180px").set("color", "var(--lumo-secondary-text-color)");
row.add(lbl);
CustomerInvoice related = invoiceLifecycleService.findById(invoiceId).orElse(null);
String number = related != null && related.getInvoiceNumber() != null ? related.getInvoiceNumber()
: fallbackNumber != null ? fallbackNumber : invoiceId;
if (related != null && related.getPdfData() != null && related.getPdfData().length > 0) {
Anchor link = new Anchor("javascript:void(0)", number);
link.getElement().addEventListener("click", e -> downloadInvoicePdf(related));
row.add(link);
} else {
row.add(new Span(number));
}
return row;
}
private Div renderAuditEntry(InvoiceAuditEntry entry) {
Div container = new Div();
container.getStyle().set("padding", "8px 12px").set("margin-bottom", "6px")
.set("border-left", "3px solid var(--lumo-contrast-30pct)")
.set("background", "var(--lumo-contrast-5pct)");
String timestamp = entry.getTimestamp() != null ? entry.getTimestamp().format(DATE_TIME_FMT) : "";
String actionLabel = entry.getAction() != null
? getTranslation("invoices.audit.action." + entry.getAction().name().toLowerCase(Locale.ROOT))
: "?";
String userLabel = entry.getUserDisplayName() != null ? entry.getUserDisplayName() : "system";
Span header = new Span(timestamp + " · " + actionLabel + " · " + userLabel);
header.getStyle().set("font-weight", "600");
container.add(header);
if (entry.getReason() != null && !entry.getReason().isBlank()) {
Div reason = new Div();
reason.setText(entry.getReason());
reason.getStyle().set("margin-top", "4px");
container.add(reason);
}
if (entry.getResultingInvoiceNumber() != null) {
Div link = new Div();
link.setText(getTranslation("invoices.audit.resulting", entry.getResultingInvoiceNumber()));
link.getStyle().set("margin-top", "4px").set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
container.add(link);
}
return container;
} }
private void downloadInvoicePdf(CustomerInvoice invoice) { private void downloadInvoicePdf(CustomerInvoice invoice) {
@@ -123,10 +608,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
} }
} }
private boolean hasPdfData(CustomerInvoice invoice) {
return invoice != null && invoice.getPdfData() != null && invoice.getPdfData().length > 0;
}
private String getRecipientLabel(CustomerInvoice invoice) { private String getRecipientLabel(CustomerInvoice invoice) {
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), ""); return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
} }

View File

@@ -2,22 +2,38 @@ package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Main; import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Span; 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.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant; import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.HasUrlParameter; import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility; import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobHistoryType; import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.JobHistoryService; import de.assecutor.votianlt.service.JobHistoryService;
@@ -25,23 +41,50 @@ import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed("USER") @RolesAllowed("USER")
@Slf4j @Slf4j
public class JobManualCompleteView extends Main implements HasUrlParameter<String>, HasDynamicTitle { public class JobManualCompleteView extends Main implements HasUrlParameter<String>, HasDynamicTitle {
private static final class ServiceRow {
private final Service service;
private final JobServiceSelection selection;
private ServiceRow(Service service, JobServiceSelection selection) {
this.service = service;
this.selection = selection;
}
}
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final JobHistoryService jobHistoryService; private final JobHistoryService jobHistoryService;
private final SecurityService securityService; private final SecurityService securityService;
private final ServiceRepository serviceRepository;
private final VerticalLayout content; private final VerticalLayout content;
private Job job;
private final List<ServiceRow> serviceRows = new ArrayList<>();
private Grid<ServiceRow> servicesGrid;
private Span netTotalLabel;
private Span grossTotalLabel;
private TextArea remarkArea;
private BigDecimal vatRate = Service.FIXED_VAT_RATE;
private Double manualDistanceKm;
private Integer manualDurationSeconds;
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService, public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService) { SecurityService securityService, ServiceRepository serviceRepository) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService; this.jobHistoryService = jobHistoryService;
this.securityService = securityService; this.securityService = securityService;
this.serviceRepository = serviceRepository;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -66,6 +109,8 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
@Override @Override
public void setParameter(BeforeEvent event, String parameter) { public void setParameter(BeforeEvent event, String parameter) {
content.removeAll(); content.removeAll();
serviceRows.clear();
job = null;
if (parameter == null || parameter.isBlank()) { if (parameter == null || parameter.isBlank()) {
content.add(new Span(getTranslation("jobhistory.error.no.id"))); content.add(new Span(getTranslation("jobhistory.error.no.id")));
@@ -80,16 +125,32 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
return; return;
} }
Job job = jobRepository.findById(jobId).orElse(null); Job loaded = jobRepository.findById(jobId).orElse(null);
if (job == null) { if (loaded == null) {
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter))); content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
return; return;
} }
render(job); job = loaded;
loadVatRate();
render();
} }
private void render(Job job) { private void loadVatRate() {
try {
User user = securityService.getCurrentDatabaseUser();
if (user != null && user.getVatRate() != null) {
vatRate = user.getVatRate();
}
} catch (Exception e) {
log.warn("Could not load user VAT rate, falling back to default: {}", e.getMessage());
}
}
private void render() {
manualDistanceKm = job.getRouteDistanceKm();
manualDurationSeconds = job.getRouteDurationSeconds();
Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber())); Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber()));
warningText.getStyle().set("color", "var(--lumo-error-text-color)"); warningText.getStyle().set("color", "var(--lumo-error-text-color)");
@@ -100,9 +161,16 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
content.add(warningText, reasonField); content.add(warningText, reasonField);
boolean hasRouteData = manualDistanceKm != null && manualDistanceKm > 0;
content.add(hasRouteData ? createRouteSection() : createManualRouteSection());
content.add(createServicesSection());
content.add(createSummarySection());
content.add(createRemarkSection());
HorizontalLayout buttonBar = new HorizontalLayout(); HorizontalLayout buttonBar = new HorizontalLayout();
buttonBar.setWidthFull(); buttonBar.setWidthFull();
buttonBar.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END); buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
buttonBar.setSpacing(true); buttonBar.setSpacing(true);
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"), Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
@@ -110,43 +178,521 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm")); Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR); confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
confirmButton.addClickListener(e -> { confirmButton.addClickListener(e -> confirm(reasonField));
String reason = reasonField.getValue();
if (reason == null || reason.trim().isEmpty()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
return;
}
try {
JobStatus oldStatus = job.getStatus();
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
String currentUser = securityService.getCurrentUsername();
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s",
currentUser, reason.trim());
jobHistoryService.logCustomEvent(job.getId(),
getTranslation("jobsummary.history.manualcomplete.reason"),
description, currentUser, JobHistoryType.STATUS_CHANGE);
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
} catch (Exception ex) {
Notification
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
buttonBar.add(cancelButton, confirmButton); buttonBar.add(cancelButton, confirmButton);
content.add(buttonBar); content.add(buttonBar);
loadSelectedServicesFromJob();
}
private VerticalLayout createRouteSection() {
VerticalLayout routeBox = new VerticalLayout();
routeBox.setPadding(true);
routeBox.setSpacing(true);
routeBox.setWidthFull();
routeBox.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
routeBox.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
routeBox.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
routeBox.addClassName("route-card");
H3 routeTitle = new H3(getTranslation("addjob.route.title"));
routeTitle.getStyle().set("margin", "0");
routeTitle.getStyle().set("color", "var(--lumo-primary-text-color)");
HorizontalLayout distanceRow = new HorizontalLayout();
distanceRow.setWidthFull();
distanceRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
distanceRow.setAlignItems(FlexComponent.Alignment.CENTER);
Span distanceLabel = new Span(getTranslation("addjob.route.distance") + ":");
Span distanceValue = new Span(formatDistance(job.getRouteDistanceKm()));
distanceValue.getStyle().set("font-weight", "bold");
distanceValue.getStyle().set("font-size", "var(--lumo-font-size-l)");
distanceValue.getStyle().set("color", "var(--lumo-primary-text-color)");
distanceRow.add(distanceLabel, distanceValue);
HorizontalLayout durationRow = new HorizontalLayout();
durationRow.setWidthFull();
durationRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
durationRow.setAlignItems(FlexComponent.Alignment.CENTER);
Span durationLabel = new Span(getTranslation("addjob.route.duration") + ":");
Span durationValue = new Span(formatDuration(job.getRouteDurationSeconds()));
durationValue.getStyle().set("font-weight", "bold");
durationValue.getStyle().set("color", "var(--lumo-secondary-text-color)");
durationRow.add(durationLabel, durationValue);
routeBox.add(routeTitle, distanceRow, durationRow);
return routeBox;
}
private VerticalLayout createManualRouteSection() {
VerticalLayout box = new VerticalLayout();
box.setPadding(true);
box.setSpacing(true);
box.setWidthFull();
box.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
box.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
box.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
box.addClassName("route-card");
H3 title = new H3(getTranslation("addjob.route.title"));
title.getStyle().set("margin", "0");
title.getStyle().set("color", "var(--lumo-primary-text-color)");
NumberField distanceField = new NumberField(getTranslation("addjob.route.distance.km"));
distanceField.setMin(0);
distanceField.setStep(0.1);
distanceField.setPlaceholder(getTranslation("addjob.route.distance.placeholder"));
distanceField.setWidthFull();
IntegerField hoursField = new IntegerField(getTranslation("jobmanualcomplete.route.hours"));
hoursField.setMin(0);
hoursField.setStepButtonsVisible(true);
hoursField.setWidthFull();
IntegerField minutesField = new IntegerField(getTranslation("jobmanualcomplete.route.minutes"));
minutesField.setMin(0);
minutesField.setMax(59);
minutesField.setStepButtonsVisible(true);
minutesField.setWidthFull();
if (manualDurationSeconds != null && manualDurationSeconds > 0) {
hoursField.setValue(manualDurationSeconds / 3600);
minutesField.setValue((manualDurationSeconds % 3600) / 60);
}
distanceField.addValueChangeListener(e -> {
manualDistanceKm = e.getValue();
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
});
Runnable recalcDuration = () -> {
Integer h = hoursField.getValue();
Integer m = minutesField.getValue();
int hours = h != null ? Math.max(0, h) : 0;
int minutes = m != null ? Math.max(0, Math.min(59, m)) : 0;
int total = hours * 3600 + minutes * 60;
manualDurationSeconds = total > 0 ? total : null;
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
};
hoursField.addValueChangeListener(e -> recalcDuration.run());
minutesField.addValueChangeListener(e -> recalcDuration.run());
HorizontalLayout durationRow = new HorizontalLayout(hoursField, minutesField);
durationRow.setWidthFull();
durationRow.setSpacing(true);
hoursField.getStyle().set("flex", "1");
minutesField.getStyle().set("flex", "1");
Span hint = new Span(getTranslation("jobmanualcomplete.route.manual.hint"));
hint.getStyle().set("font-size", "var(--lumo-font-size-s)");
hint.getStyle().set("color", "var(--lumo-secondary-text-color)");
hint.getStyle().set("font-style", "italic");
box.add(title, distanceField, durationRow, hint);
return box;
}
private VerticalLayout createServicesSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(true);
section.setWidthFull();
H3 title = new H3(getTranslation("addjob.services.title"));
title.getStyle().set("margin", "0");
servicesGrid = new Grid<>();
servicesGrid.setWidthFull();
servicesGrid.setHeight("250px");
servicesGrid.setItems(serviceRows);
servicesGrid.addClassName("data-grid");
servicesGrid.addColumn(row -> row.service != null ? row.service.getName() : "")
.setHeader(getTranslation("common.service")).setSortable(true);
servicesGrid.addColumn(row -> formatDeliveryStationLabel(
row.selection != null ? row.selection.getDeliveryStationOrder() : null))
.setHeader(getTranslation("addjob.services.deliverystation")).setSortable(false);
servicesGrid.addColumn(row -> formatCalculationBasis(row.service))
.setHeader(getTranslation("addjob.services.calculation")).setSortable(true);
servicesGrid.addColumn(this::formatPrice).setHeader(getTranslation("common.price")).setSortable(false);
servicesGrid.addComponentColumn(row -> {
if (row.service != null && row.service.isMandatory()) {
return new Span("");
}
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY,
ButtonVariant.LUMO_SMALL);
removeButton.addClickListener(e -> {
serviceRows.remove(row);
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
});
return removeButton;
}).setHeader(getTranslation("common.actions")).setAutoWidth(true).setFlexGrow(0);
Div gridPanel = new Div(servicesGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull();
Button addButton = new Button(getTranslation("addjob.services.add"), new Icon(VaadinIcon.PLUS));
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
addButton.addClickListener(e -> openAddServiceDialog());
section.add(title, gridPanel, addButton);
return section;
}
private VerticalLayout createSummarySection() {
VerticalLayout summary = new VerticalLayout();
summary.setPadding(true);
summary.setSpacing(true);
summary.setWidthFull();
summary.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
summary.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
summary.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
summary.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
summary.addClassName("summary-card");
H3 title = new H3(getTranslation("addjob.summary.title"));
title.getStyle().set("margin", "0");
summary.add(title);
Div priceTable = new Div();
priceTable.getStyle().set("width", "100%");
Div netRow = new Div();
netRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
Span netLabel = new Span(getTranslation("addjob.summary.net") + ":");
netLabel.getStyle().set("padding-right", "8px");
netTotalLabel = new Span("0,00 €");
netTotalLabel.getStyle().set("font-weight", "bold").set("white-space", "nowrap");
netRow.add(netLabel, netTotalLabel);
Div grossRow = new Div();
grossRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
Span grossLabel = new Span(getTranslation("addjob.summary.gross") + ":");
grossLabel.getStyle().set("padding-right", "8px").set("font-weight", "bold");
grossTotalLabel = new Span("0,00 €");
grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
grossTotalLabel.getStyle().set("font-weight", "bold");
grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)").set("white-space", "nowrap");
grossRow.add(grossLabel, grossTotalLabel);
priceTable.add(netRow, grossRow);
summary.add(priceTable);
return summary;
}
private VerticalLayout createRemarkSection() {
VerticalLayout section = new VerticalLayout();
section.setPadding(false);
section.setSpacing(true);
section.setWidthFull();
H3 title = new H3(getTranslation("addjob.tasks.remark"));
title.getStyle().set("margin", "0");
remarkArea = new TextArea();
remarkArea.setPlaceholder(getTranslation("addjob.tasks.remark.placeholder"));
remarkArea.setWidthFull();
remarkArea.setMinHeight("120px");
if (job.getRemark() != null) {
remarkArea.setValue(job.getRemark());
}
section.add(title, remarkArea);
return section;
}
private void loadSelectedServicesFromJob() {
serviceRows.clear();
if (job.getSelectedServices() != null && !job.getSelectedServices().isEmpty()) {
for (JobServiceSelection selection : job.getSelectedServices()) {
if (selection.getServiceId() == null) {
continue;
}
serviceRepository.findById(selection.getServiceId())
.ifPresent(service -> serviceRows.add(new ServiceRow(service, selection)));
}
} else if (job.getServiceIds() != null && !job.getServiceIds().isEmpty()) {
for (String serviceId : job.getServiceIds()) {
serviceRepository.findById(serviceId)
.ifPresent(service -> serviceRows.add(new ServiceRow(service, null)));
}
}
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
}
private void openAddServiceDialog() {
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.services.dialog.title"), "720px");
dialog.setCloseOnOutsideClick(false);
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("620px");
User currentUser = securityService.getCurrentDatabaseUser();
List<Service> availableServices = currentUser != null
? serviceRepository.findByUserId(currentUser.getId().toString())
: List.of();
ComboBox<Service> serviceCombo = new ComboBox<>(getTranslation("common.service"));
serviceCombo.setWidthFull();
serviceCombo.setItems(availableServices);
serviceCombo.setItemLabelGenerator(service -> {
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE
&& service.getEffectivePrice() != null) {
return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)";
}
return service.getName();
});
serviceCombo.setPlaceholder(getTranslation("addjob.services.dialog.placeholder"));
serviceCombo.setRequired(true);
List<Integer> stationOrders = availableDeliveryStationOrders();
ComboBox<Integer> stationCombo = new ComboBox<>(getTranslation("addjob.services.deliverystation"));
stationCombo.setWidthFull();
stationCombo.setRequired(true);
stationCombo.setRequiredIndicatorVisible(true);
stationCombo.setItems(stationOrders);
stationCombo.setItemLabelGenerator(this::buildDeliveryStationSelectionLabel);
stationCombo.setPlaceholder(getTranslation("addjob.services.dialog.station.placeholder"));
if (!stationOrders.isEmpty()) {
stationCombo.setValue(0);
}
dialogContent.add(serviceCombo, stationCombo);
Button cancel = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button add = new Button(getTranslation("addjob.services.dialog.add"), e -> {
Service service = serviceCombo.getValue();
Integer stationOrder = stationCombo.getValue();
if (service == null || stationOrder == null) {
return;
}
JobServiceSelection selection = new JobServiceSelection();
selection.setServiceId(service.getId());
selection.setDeliveryStationOrder(stationOrder);
selection.setRouteDistanceKm(manualDistanceKm);
selection.setRouteDurationSeconds(manualDurationSeconds);
serviceRows.add(new ServiceRow(service, selection));
servicesGrid.getDataProvider().refreshAll();
updatePriceSummary();
dialog.close();
});
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
dialog.getFooter().add(cancel, add);
dialog.open();
}
private List<Integer> availableDeliveryStationOrders() {
List<Integer> orders = new ArrayList<>();
if (job.getDeliveryStations() != null) {
for (int i = 0; i < job.getDeliveryStations().size(); i++) {
orders.add(i);
}
}
return orders;
}
private String buildDeliveryStationSelectionLabel(Integer order) {
if (order == null || job.getDeliveryStations() == null || order < 0
|| order >= job.getDeliveryStations().size()) {
return "-";
}
DeliveryStation station = job.getDeliveryStations().get(order);
StringBuilder label = new StringBuilder(getTranslation("addjob.station.delivery", order + 1));
if (station.getCity() != null && !station.getCity().isBlank()) {
label.append(" - ").append(station.getCity());
} else if (station.getCompany() != null && !station.getCompany().isBlank()) {
label.append(" - ").append(station.getCompany());
}
return label.toString();
}
private String formatDeliveryStationLabel(Integer order) {
if (order == null || order < 0) {
return "-";
}
return getTranslation("addjob.station.delivery", order + 1);
}
private String formatCalculationBasis(Service service) {
if (service == null || service.getCalculationBasis() == null) {
return "";
}
return switch (service.getCalculationBasis()) {
case DISTANCE -> getTranslation("addjob.services.basis.distance");
case TIME -> getTranslation("addjob.services.basis.time");
case FLAT_RATE -> getTranslation("addjob.services.basis.flatrate");
};
}
private String formatPrice(ServiceRow row) {
Service service = row.service;
if (service == null || service.getCalculationBasis() == null) {
return "";
}
BigDecimal price = calculateServicePrice(row);
if (price != null && price.compareTo(BigDecimal.ZERO) > 0) {
return price.setScale(2, RoundingMode.HALF_UP) + "";
}
if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE && service.getPricePerKilometer() != null
&& routeDistanceFor(row.selection) == null) {
return service.getPricePerKilometer().setScale(2, RoundingMode.HALF_UP) + " €/km ("
+ getTranslation("addjob.services.route.missing") + ")";
}
if (service.getCalculationBasis() == Service.CalculationBasis.TIME && service.getPricePer15Minutes() != null
&& routeDurationFor(row.selection) == null) {
return service.getPricePer15Minutes().setScale(2, RoundingMode.HALF_UP) + " €/15 Min. ("
+ getTranslation("addjob.services.route.missing") + ")";
}
return service.getEffectivePrice() != null
? service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + ""
: "";
}
private BigDecimal calculateServicePrice(ServiceRow row) {
Service service = row.service;
if (service == null || service.getCalculationBasis() == null) {
return BigDecimal.ZERO;
}
switch (service.getCalculationBasis()) {
case FLAT_RATE:
return service.getPrice() != null ? service.getPrice() : BigDecimal.ZERO;
case DISTANCE: {
Double km = routeDistanceFor(row.selection);
if (service.getPricePerKilometer() != null && km != null && km > 0) {
return service.getPricePerKilometer().multiply(BigDecimal.valueOf(km));
}
return BigDecimal.ZERO;
}
case TIME: {
Integer seconds = routeDurationFor(row.selection);
if (service.getPricePer15Minutes() != null && seconds != null && seconds > 0) {
int units = seconds / 900;
if (seconds % 900 > 0) {
units++;
}
return service.getPricePer15Minutes().multiply(BigDecimal.valueOf(units));
}
return BigDecimal.ZERO;
}
default:
return BigDecimal.ZERO;
}
}
private Double routeDistanceFor(JobServiceSelection selection) {
if (selection != null && selection.getRouteDistanceKm() != null) {
return selection.getRouteDistanceKm();
}
return manualDistanceKm;
}
private Integer routeDurationFor(JobServiceSelection selection) {
if (selection != null && selection.getRouteDurationSeconds() != null) {
return selection.getRouteDurationSeconds();
}
return manualDurationSeconds;
}
private void updatePriceSummary() {
BigDecimal net = BigDecimal.ZERO;
for (ServiceRow row : serviceRows) {
net = net.add(calculateServicePrice(row));
}
BigDecimal gross = net.add(net.multiply(vatRate));
netTotalLabel.setText(formatAmount(net));
grossTotalLabel.setText(formatAmount(gross));
}
private String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + "";
}
private String formatDistance(Double km) {
if (km == null) {
return "-";
}
return String.format(Locale.GERMANY, "%.1f km", km);
}
private String formatDuration(Integer seconds) {
if (seconds == null || seconds <= 0) {
return "-";
}
int hours = seconds / 3600;
int minutes = (seconds % 3600) / 60;
if (hours > 0) {
return String.format("%d Std. %d Min.", hours, minutes);
}
return String.format("%d Min.", minutes);
}
private void confirm(TextArea reasonField) {
String reason = reasonField.getValue();
if (reason == null || reason.trim().isEmpty()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
return;
}
try {
JobStatus oldStatus = job.getStatus();
List<JobServiceSelection> selections = new ArrayList<>();
for (ServiceRow row : serviceRows) {
if (row.service == null) {
continue;
}
JobServiceSelection selection = row.selection != null ? row.selection : new JobServiceSelection();
selection.setServiceId(row.service.getId());
if (selection.getDeliveryStationOrder() == null && row.selection != null) {
selection.setDeliveryStationOrder(row.selection.getDeliveryStationOrder());
}
selections.add(selection);
}
job.setSelectedServices(selections);
String remark = remarkArea.getValue();
job.setRemark(remark != null && !remark.isBlank() ? remark.trim() : null);
if (job.getRouteDistanceKm() == null || job.getRouteDistanceKm() <= 0) {
job.setRouteDistanceKm(manualDistanceKm);
job.setRouteDurationSeconds(manualDurationSeconds);
}
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
String currentUser = securityService.getCurrentUsername();
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s", currentUser,
reason.trim());
jobHistoryService.logCustomEvent(job.getId(), getTranslation("jobsummary.history.manualcomplete.reason"),
description, currentUser, JobHistoryType.STATUS_CHANGE);
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
} catch (Exception ex) {
Notification
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
} }
} }

View File

@@ -50,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
private final SecurityService securityService; private final SecurityService securityService;
private final ClientConnectionService clientConnectionService; private final ClientConnectionService clientConnectionService;
private final MessagingPublisher messagingPublisher; private final MessagingPublisher messagingPublisher;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final Grid<Job> grid = new Grid<>(Job.class, false); private final Grid<Job> grid = new Grid<>(Job.class, false);
@Autowired @Autowired
@@ -61,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
this.securityService = securityService; this.securityService = securityService;
this.clientConnectionService = clientConnectionService; this.clientConnectionService = clientConnectionService;
this.messagingPublisher = messagingPublisher; this.messagingPublisher = messagingPublisher;
this.customerInvoiceRepository = customerInvoiceRepository;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.repository; package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
Optional<CustomerInvoice> findByJobId(String jobId); Optional<CustomerInvoice> findByJobId(String jobId);
List<CustomerInvoice> findByUserId(String userId); List<CustomerInvoice> findByUserId(String userId);
/** Liefert die höchstens eine aktive (nicht stornierte) Rechnung mit dieser Nummer (R-11). */
Optional<CustomerInvoice> findByInvoiceNumberAndStatusNot(String invoiceNumber, InvoiceStatus status);
/** Alle Folgebelege (Storno, Korrektur, Ersatzrechnung), die auf diese Originalrechnung verweisen. */
List<CustomerInvoice> findByOriginalInvoiceId(String originalInvoiceId);
/** Findet alle Rechnungen ohne expliziten Status — wird für die Bestandsdatenmigration genutzt. */
List<CustomerInvoice> findByStatusIsNull();
} }

View File

@@ -0,0 +1,23 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface InvoiceNumberReservationRepository extends MongoRepository<InvoiceNumberReservation, ObjectId> {
Optional<InvoiceNumberReservation> findByUserIdAndNumber(ObjectId userId, String number);
Optional<InvoiceNumberReservation> findByUserIdAndSequence(ObjectId userId, long sequence);
List<InvoiceNumberReservation> findByUserIdOrderBySequenceAsc(ObjectId userId);
List<InvoiceNumberReservation> findByUserIdAndStatusOrderBySequenceAsc(ObjectId userId,
InvoiceNumberReservationStatus status);
}

View File

@@ -0,0 +1,26 @@
package de.assecutor.votianlt.security;
/**
* Rollen für die Bearbeitung von Rechnungen gemäß R-40 bis R-42.
*
* Die Rollen sind als Konstanten definiert und werden in der bestehenden
* {@link de.assecutor.votianlt.model.User#getRoles()}-Sammlung als String hinterlegt.
*
* Backwards-compat: Bestehende Nutzer haben keine dieser Rollen — die
* {@code USER}-Rolle bleibt vollumfänglich berechtigt, sofern keine speziellen
* Rechnungsrollen explizit zugewiesen sind.
*/
public final class InvoiceRoles {
/** Erstellt Entwürfe und stellt Rechnungen aus. */
public static final String CREATOR = "INVOICE_CREATOR";
/** Prüft Entwürfe und Folgebelege vor Freigabe. */
public static final String REVIEWER = "INVOICE_REVIEWER";
/** Gibt Storno- und Berichtigungsbelege frei (R-42). */
public static final String APPROVER = "INVOICE_APPROVER";
/** Erfasst Zahlungen und buchhalterische Vorgänge (R-25). */
public static final String ACCOUNTANT = "INVOICE_ACCOUNTANT";
private InvoiceRoles() {
}
}

View File

@@ -0,0 +1,71 @@
package de.assecutor.votianlt.service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.SecureRandom;
/**
* Symmetrische AES-256-GCM-Verschlüsselung mit Master-Key-Ableitung über SHA-256.
*
* Format der erzeugten Bytes: {@code IV (12 Byte) || Ciphertext+Tag (16 Byte Tag)}.
* Der IV wird pro Verschlüsselung neu zufällig erzeugt; ein Master-Key liefert
* deterministisch denselben AES-Schlüssel.
*
* Nicht für hochfrequente Krypto-Operationen optimiert — bewusst minimal gehalten.
*/
final class AesGcmCipher {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
AesGcmCipher(String masterKey) {
if (masterKey == null || masterKey.length() < 16) {
throw new IllegalArgumentException("Master-Key muss mindestens 16 Zeichen lang sein.");
}
try {
byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes("UTF-8"));
this.key = new SecretKeySpec(derived, "AES");
} catch (Exception ex) {
throw new IllegalStateException("Master-Key konnte nicht abgeleitet werden.", ex);
}
}
byte[] encrypt(byte[] plaintext) {
try {
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] ciphertext = cipher.doFinal(plaintext);
return ByteBuffer.allocate(IV_LENGTH + ciphertext.length).put(iv).put(ciphertext).array();
} catch (Exception ex) {
throw new IllegalStateException("Verschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
byte[] decrypt(byte[] ivAndCiphertext) {
if (ivAndCiphertext == null || ivAndCiphertext.length < IV_LENGTH + 16) {
throw new IllegalArgumentException("Ciphertext zu kurz.");
}
try {
ByteBuffer buffer = ByteBuffer.wrap(ivAndCiphertext);
byte[] iv = new byte[IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
return cipher.doFinal(ciphertext);
} catch (Exception ex) {
throw new IllegalStateException("Entschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
}

View File

@@ -921,4 +921,181 @@ public class CustomerInvoiceService {
return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;") return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("'", "&#x27;"); .replace("'", "&#x27;");
} }
/**
* Erzeugt ein einfaches PDF für einen Stornobeleg gemäß R-19.
* Verweist eindeutig auf die Originalrechnung (Nummer + Datum) und stellt die
* Beträge als negative Werte dar.
*/
public byte[] generateCancellationPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
String cancellationNumber, java.time.LocalDate cancellationDate, String reason) throws Exception {
return generateCorrectionDocumentPdf("STORNORECHNUNG", original, cancellationNumber, cancellationDate, reason,
null, true);
}
/**
* Erzeugt ein einfaches PDF für einen Berichtigungsbeleg gemäß R-13/R-14.
* Verweist eindeutig auf die Originalrechnung und beschreibt die berichtigten Angaben.
*/
public byte[] generateCorrectionPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
String correctionNumber, java.time.LocalDate correctionDate, String reason, String correctedFields)
throws Exception {
return generateCorrectionDocumentPdf("RECHNUNGSBERICHTIGUNG", original, correctionNumber, correctionDate,
reason, correctedFields, false);
}
private byte[] generateCorrectionDocumentPdf(String documentLabel,
de.assecutor.votianlt.model.invoices.CustomerInvoice original, String number,
java.time.LocalDate documentDate, String reason, String correctedFields, boolean negateAmounts)
throws Exception {
java.time.LocalDate effectiveDate = documentDate != null ? documentDate : java.time.LocalDate.now();
java.time.format.DateTimeFormatter dateFmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy",
Locale.GERMANY);
BigDecimal net = negateAmounts ? negateOrZero(original.getNetAmount()) : safeAmount(original.getNetAmount());
BigDecimal vat = negateAmounts ? negateOrZero(original.getVatAmount()) : safeAmount(original.getVatAmount());
BigDecimal total = negateAmounts ? negateOrZero(original.getTotalAmount())
: safeAmount(original.getTotalAmount());
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html><html><head><meta charset='UTF-8'><style>");
html.append("@page { size: A4; margin: 20mm 18mm 20mm 18mm; }");
html.append("body { font-family: Arial, sans-serif; font-size: 11pt; color: #222; }");
html.append("h1 { font-size: 18pt; letter-spacing: 0.05em; margin: 0 0 8pt 0; }");
html.append("h2 { font-size: 13pt; margin: 18pt 0 6pt 0; }");
html.append(".doc-number { font-size: 11pt; color: #555; }");
html.append(".section { margin-top: 14pt; }");
html.append(".reference { background: #f6f6f6; border-left: 3px solid #888; padding: 8pt 12pt; }");
html.append(".reason { background: #fff8e6; border-left: 3px solid #d9a300; padding: 8pt 12pt; }");
html.append("table.amounts { width: 60%; margin-left: 40%; border-collapse: collapse; margin-top: 10pt; }");
html.append("table.amounts td { padding: 3pt 6pt; }");
html.append("table.amounts td.value { text-align: right; }");
html.append("table.amounts tr.total td { font-weight: bold; border-top: 1px solid #333; }");
html.append(".addresses { width: 100%; margin-top: 14pt; }");
html.append(".addresses td { width: 50%; vertical-align: top; }");
html.append(".muted { color: #666; font-size: 9pt; }");
html.append("</style></head><body>");
html.append("<h1>").append(escapeHtml(documentLabel)).append("</h1>");
html.append("<div class='doc-number'>Beleg-Nr.: ")
.append(escapeHtml(safe(number)))
.append(" &nbsp;·&nbsp; Datum: ").append(escapeHtml(effectiveDate.format(dateFmt)))
.append("</div>");
// Sender / Empfänger
html.append("<table class='addresses'><tr><td>");
html.append("<strong>Aussteller</strong><br/>");
html.append(formatAddressBlock(original.getSenderName(), original.getSenderAddress(),
original.getSenderPostcode(), original.getSenderCity(), original.getSenderCountry()));
if (original.getSenderTaxNumber() != null && !original.getSenderTaxNumber().isBlank()) {
html.append("<div class='muted'>Steuernr.: ").append(escapeHtml(original.getSenderTaxNumber()))
.append("</div>");
}
if (original.getSenderVatId() != null && !original.getSenderVatId().isBlank()) {
html.append("<div class='muted'>USt-IdNr.: ").append(escapeHtml(original.getSenderVatId()))
.append("</div>");
}
html.append("</td><td>");
html.append("<strong>Empfänger</strong><br/>");
html.append(formatAddressBlock(
firstNonBlank(original.getRecipientCompany(), original.getRecipientName()),
original.getRecipientAddress(), original.getRecipientPostcode(), original.getRecipientCity(),
original.getRecipientCountry()));
html.append("</td></tr></table>");
// Eindeutige Referenz auf Originalrechnung (R-13/R-19/R-28)
html.append("<div class='section reference'>");
html.append("<strong>Bezug:</strong> ");
html.append("Diese ").append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))).append(" bezieht sich ");
html.append("eindeutig auf die Rechnung <strong>")
.append(escapeHtml(safe(original.getInvoiceNumber()))).append("</strong>");
if (original.getInvoiceDate() != null) {
html.append(" vom ").append(escapeHtml(original.getInvoiceDate().format(dateFmt)));
}
html.append(".");
html.append("</div>");
if (correctedFields != null && !correctedFields.isBlank()) {
html.append("<div class='section'><h2>Berichtigte Angaben</h2>");
html.append("<div>").append(escapeHtml(correctedFields).replace("\n", "<br/>")).append("</div></div>");
}
if (reason != null && !reason.isBlank()) {
html.append("<div class='section reason'><strong>Grund:</strong> ")
.append(escapeHtml(reason).replace("\n", "<br/>")).append("</div>");
}
html.append("<h2>Beträge</h2>");
html.append("<table class='amounts'>");
html.append("<tr><td>Nettobetrag</td><td class='value'>").append(formatCurrency(net)).append("</td></tr>");
if (original.getVatRate() != null) {
BigDecimal vatPct = original.getVatRate().multiply(new BigDecimal("100"))
.setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros();
if (vatPct.scale() < 0) {
vatPct = vatPct.setScale(0);
}
html.append("<tr><td>zzgl. ").append(vatPct.toPlainString().replace('.', ','))
.append("% USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
} else {
html.append("<tr><td>zzgl. USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
}
html.append("<tr class='total'><td>Gesamtbetrag</td><td class='value'>").append(formatCurrency(total))
.append("</td></tr>");
html.append("</table>");
html.append("<div class='section muted'>");
html.append("Hinweis: Dieser Beleg ersetzt die Originalrechnung nicht. Original und ");
html.append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY)));
html.append(" sind gemeinsam aufzubewahren.");
html.append("</div>");
html.append("</body></html>");
return generatePdfFromHtmlString(html.toString());
}
private BigDecimal safeAmount(BigDecimal value) {
return value != null ? value : BigDecimal.ZERO;
}
private BigDecimal negateOrZero(BigDecimal value) {
return value != null ? value.negate() : BigDecimal.ZERO;
}
private String formatAddressBlock(String name, String street, String postcode, String city, String country) {
StringBuilder sb = new StringBuilder();
if (name != null && !name.isBlank()) {
sb.append(escapeHtml(name)).append("<br/>");
}
if (street != null && !street.isBlank()) {
sb.append(escapeHtml(street)).append("<br/>");
}
String line = String.join(" ", filterBlanks(postcode, city)).trim();
if (!line.isEmpty()) {
sb.append(escapeHtml(line)).append("<br/>");
}
if (country != null && !country.isBlank()) {
sb.append(escapeHtml(country));
}
return sb.toString();
}
private java.util.List<String> filterBlanks(String... values) {
java.util.List<String> out = new java.util.ArrayList<>();
for (String v : values) {
if (v != null && !v.isBlank()) {
out.add(v);
}
}
return out;
}
private String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) {
return v;
}
}
return "";
}
} }

View File

@@ -0,0 +1,35 @@
package de.assecutor.votianlt.service;
import java.util.Collections;
import java.util.List;
/**
* Wird geworfen, wenn eine Rechnung beim Festschreiben gegen Pflichtangaben
* nach § 14 UStG bzw. interne Konsistenzregeln verstößt. Die Verstöße werden
* gesammelt geliefert, damit der Anwender alle Korrekturen in einem Schritt
* durchführen kann statt jeden Fehler einzeln zu beheben.
*/
public class InvoiceComplianceException extends InvoiceLifecycleException {
private final List<String> violations;
public InvoiceComplianceException(List<String> violations) {
super(buildMessage(violations));
this.violations = List.copyOf(violations);
}
public List<String> getViolations() {
return Collections.unmodifiableList(violations);
}
private static String buildMessage(List<String> violations) {
if (violations == null || violations.isEmpty()) {
return "Die Rechnung erfüllt die gesetzlichen Pflichtangaben nicht.";
}
StringBuilder sb = new StringBuilder("Die Rechnung kann nicht festgeschrieben werden — folgende Pflichtangaben fehlen oder sind inkonsistent:");
for (String violation : violations) {
sb.append("\n • ").append(violation);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,189 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/**
* Prüft eine {@link CustomerInvoice} auf die Pflichtangaben nach § 14 UStG
* sowie auf interne Konsistenz (Beträge, Items). Wird vor jeder Festschreibung
* (Übergang DRAFT → ISSUED) aufgerufen; eine festgeschriebene Rechnung darf
* keine Pflichtfeld-Lücken mehr haben, da sie nach R-08 nicht mehr direkt
* änderbar ist.
*
* Nicht abgedeckt (bewusst):
* <ul>
* <li>Lückenlose Rechnungsnummer-Vergabe (separater Block).</li>
* <li>Online-Validierung der USt-IdNr beim Bzst (separater Block).</li>
* <li>Storno-/Korrekturbelege — diese haben eigene Beleg-Regeln (negierte Beträge,
* Pflicht-Verweis auf Originalrechnung), die hier nicht greifen.</li>
* </ul>
*
* Toleranz für Beträge: 1 Cent. Damit fängt der Validator typische Rundungs-
* differenzen aus dezimaler Arithmetik ab, ohne echte Inkonsistenzen zu schlucken.
*/
@Service
public class InvoiceComplianceValidator {
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
/**
* Wirft {@link InvoiceComplianceException} mit allen gefundenen Verstößen,
* wenn die Rechnung nicht festschreibungsreif ist. Andernfalls passiert nichts.
*/
public void validateForIssuance(CustomerInvoice invoice) {
if (invoice == null) {
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
}
if (invoice.getType() != null && invoice.getType() != InvoiceType.INVOICE) {
// Storno/Korrektur folgen anderen Regeln und werden hier nicht geprüft.
return;
}
List<String> violations = collectViolations(invoice);
if (!violations.isEmpty()) {
throw new InvoiceComplianceException(violations);
}
}
private List<String> collectViolations(CustomerInvoice invoice) {
List<String> violations = new ArrayList<>();
checkInvoiceNumber(invoice, violations);
checkDates(invoice, violations);
checkSender(invoice, violations);
checkRecipient(invoice, violations);
checkItems(invoice, violations);
checkAmounts(invoice, violations);
checkVatNotices(invoice, violations);
return violations;
}
private void checkInvoiceNumber(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getInvoiceNumber())) {
violations.add("Rechnungsnummer fehlt (§ 14 Abs. 4 Nr. 4 UStG).");
}
}
private void checkDates(CustomerInvoice invoice, List<String> violations) {
if (invoice.getInvoiceDate() == null) {
violations.add("Rechnungsdatum (Ausstellungsdatum) fehlt (§ 14 Abs. 4 Nr. 3 UStG).");
}
if (invoice.getDeliveryDate() == null) {
violations.add("Leistungsdatum fehlt (§ 14 Abs. 4 Nr. 6 UStG). "
+ "Bei zeitgleicher Leistung kann es dem Rechnungsdatum entsprechen — muss aber gesetzt sein.");
}
}
private void checkSender(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getSenderName())) {
violations.add("Name des Leistenden (Absender) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getSenderAddress()) || isBlank(invoice.getSenderPostcode())
|| isBlank(invoice.getSenderCity())) {
violations.add("Vollständige Anschrift des Leistenden (Straße, PLZ, Ort) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getSenderTaxNumber()) && isBlank(invoice.getSenderVatId())) {
violations.add("Steuernummer oder USt-IdNr des Leistenden fehlt (§ 14 Abs. 4 Nr. 2 UStG).");
}
}
private void checkRecipient(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getRecipientName())) {
violations.add("Name des Leistungsempfängers fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getRecipientAddress()) || isBlank(invoice.getRecipientPostcode())
|| isBlank(invoice.getRecipientCity())) {
violations.add("Vollständige Anschrift des Leistungsempfängers (Straße, PLZ, Ort) fehlt "
+ "(§ 14 Abs. 4 Nr. 1 UStG).");
}
}
private void checkItems(CustomerInvoice invoice, List<String> violations) {
List<CustomerInvoiceItem> items = invoice.getItems();
if (items == null || items.isEmpty()) {
violations.add("Keine Positionen erfasst — Menge und Art der Leistung sind erforderlich "
+ "(§ 14 Abs. 4 Nr. 5 UStG).");
return;
}
for (int i = 0; i < items.size(); i++) {
CustomerInvoiceItem item = items.get(i);
int rowNumber = i + 1;
if (item == null) {
violations.add("Position " + rowNumber + ": leere Position.");
continue;
}
if (isBlank(item.getDescription())) {
violations.add("Position " + rowNumber + ": Bezeichnung der Leistung fehlt.");
}
if (item.getQuantity() == null || item.getQuantity().signum() <= 0) {
violations.add("Position " + rowNumber + ": Menge muss größer 0 sein.");
}
if (item.getUnitPrice() == null || item.getUnitPrice().signum() < 0) {
violations.add("Position " + rowNumber + ": Einzelpreis fehlt oder ist negativ.");
}
}
}
private void checkAmounts(CustomerInvoice invoice, List<String> violations) {
BigDecimal net = invoice.getNetAmount();
BigDecimal vat = invoice.getVatAmount();
BigDecimal total = invoice.getTotalAmount();
if (net == null || vat == null || total == null) {
violations.add("Beträge unvollständig: Netto, Steuerbetrag und Bruttobetrag müssen ausgewiesen sein "
+ "(§ 14 Abs. 4 Nr. 7 + 8 UStG).");
return;
}
if (net.add(vat).subtract(total).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Bruttobetrag passt nicht zu Netto + Steuerbetrag (Differenz > 1 Cent).");
}
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
BigDecimal sumItems = invoice.getItems().stream()
.map(CustomerInvoiceItem::getNetTotal)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (sumItems.subtract(net).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Summe der Positionen (netto) " + sumItems + " weicht vom Rechnungs-Netto " + net
+ " ab (Differenz > 1 Cent).");
}
}
}
private void checkVatNotices(CustomerInvoice invoice, List<String> violations) {
BigDecimal rate = invoice.getVatRate();
if (rate == null) {
violations.add("Steuersatz fehlt (§ 14 Abs. 4 Nr. 8 UStG).");
return;
}
if (rate.signum() == 0) {
// Bei 0 % USt verlangt das UStG einen erklärenden Hinweis — entweder
// Reverse-Charge (§ 13b), Kleinunternehmerregelung (§ 19), eine
// innergemeinschaftliche Lieferung (§ 6a) oder eine andere Steuerbefreiung.
// Ohne Hinweis ist eine 0 %-Rechnung formal mangelhaft.
boolean hasNotice = !isBlank(invoice.getReverseChargeNote()) || !isBlank(invoice.getLegalNotes());
if (!hasNotice) {
violations.add("Bei 0 % USt ist ein rechtlicher Hinweis erforderlich "
+ "(z.B. \"Steuerschuldnerschaft des Leistungsempfängers\" nach § 13b UStG, "
+ "Kleinunternehmerregelung § 19 UStG oder Steuerbefreiung). "
+ "Bitte im Feld \"Reverse-Charge-Hinweis\" oder \"Rechtliche Hinweise\" ergänzen.");
}
} else {
BigDecimal expectedVat = invoice.getNetAmount() != null
? invoice.getNetAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP)
: null;
if (expectedVat != null && invoice.getVatAmount() != null
&& expectedVat.subtract(invoice.getVatAmount()).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Ausgewiesener Steuerbetrag " + invoice.getVatAmount()
+ " passt nicht zu Netto × Steuersatz (erwartet " + expectedVat + ").");
}
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -0,0 +1,182 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Export-Funktion für Rechnungen gemäß R-33 und R-34.
*
* Bündelt eine Originalrechnung mit allen erzeugten Folgebelegen
* (Storno, Berichtigung, Ersatzrechnung) sowie einer Manifest-Datei
* mit Audit-Log und Verkettungsangaben in einer ZIP-Datei.
*/
@Service
public class InvoiceExportService {
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
Locale.GERMANY);
private final CustomerInvoiceRepository invoiceRepository;
public InvoiceExportService(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
/**
* Erzeugt ein ZIP-Archiv mit dem Originalbeleg, allen verlinkten Folgebelegen
* sowie einer Manifest-Datei. Ist die übergebene Rechnung selbst ein Folgebeleg,
* wird automatisch das Bündel um die zugehörige Originalrechnung erweitert.
*/
public byte[] exportInvoicePackage(CustomerInvoice anchor) {
if (anchor == null) {
throw new IllegalArgumentException("Rechnung erforderlich.");
}
CustomerInvoice root = resolveRoot(anchor);
List<CustomerInvoice> bundle = collectBundle(root);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (CustomerInvoice invoice : bundle) {
writePdfEntry(zip, invoice);
}
String manifest = buildManifest(root, bundle);
ZipEntry manifestEntry = new ZipEntry("MANIFEST.txt");
zip.putNextEntry(manifestEntry);
zip.write(manifest.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
return baos.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException("Export fehlgeschlagen: " + ex.getMessage(), ex);
}
}
/**
* Schlägt einen Dateinamen für das ZIP-Archiv auf Basis des Originalbelegs vor.
*/
public String suggestFilename(CustomerInvoice anchor) {
CustomerInvoice root = resolveRoot(anchor);
String number = root.getInvoiceNumber() != null ? root.getInvoiceNumber() : root.getId();
return "Rechnung_" + sanitize(number) + ".zip";
}
private CustomerInvoice resolveRoot(CustomerInvoice anchor) {
if (anchor.getType() != InvoiceType.INVOICE && anchor.getOriginalInvoiceId() != null) {
return invoiceRepository.findById(anchor.getOriginalInvoiceId()).orElse(anchor);
}
return anchor;
}
private List<CustomerInvoice> collectBundle(CustomerInvoice root) {
List<CustomerInvoice> result = new ArrayList<>();
Set<String> seen = new HashSet<>();
result.add(root);
seen.add(root.getId());
for (CustomerInvoice related : invoiceRepository.findByOriginalInvoiceId(root.getId())) {
if (related.getId() != null && seen.add(related.getId())) {
result.add(related);
}
}
return result;
}
private void writePdfEntry(ZipOutputStream zip, CustomerInvoice invoice) throws java.io.IOException {
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
return;
}
String label = switch (invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE) {
case INVOICE -> "Rechnung";
case CANCELLATION -> "Storno";
case CORRECTION -> "Berichtigung";
};
String number = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : invoice.getId();
String name = sanitize(label + "_" + number) + ".pdf";
ZipEntry entry = new ZipEntry(name);
zip.putNextEntry(entry);
zip.write(invoice.getPdfData());
zip.closeEntry();
}
private String buildManifest(CustomerInvoice root, List<CustomerInvoice> bundle) {
StringBuilder sb = new StringBuilder();
sb.append("Rechnungspaket\n");
sb.append("===============\n\n");
sb.append("Originalrechnung: ").append(safe(root.getInvoiceNumber()));
if (root.getInvoiceDate() != null) {
sb.append(" vom ").append(root.getInvoiceDate());
}
sb.append("\n");
sb.append("Status: ").append(root.getStatus()).append("\n");
sb.append("Zahlungsstatus: ").append(root.getPaymentStatus()).append("\n");
if (root.getTotalAmount() != null) {
sb.append("Gesamtbetrag: ").append(root.getTotalAmount()).append("\n");
}
if (root.getPaidAmount() != null) {
sb.append("Bezahlt: ").append(root.getPaidAmount()).append("\n");
}
sb.append("\nEnthaltene Belege:\n");
for (CustomerInvoice invoice : bundle) {
sb.append("- [").append(invoice.getType()).append("] ")
.append(safe(invoice.getInvoiceNumber()));
if (invoice.getInvoiceDate() != null) {
sb.append(" vom ").append(invoice.getInvoiceDate());
}
sb.append(" — Status ").append(invoice.getStatus()).append("\n");
}
sb.append("\nÄnderungsprotokoll der Originalrechnung:\n");
List<InvoiceAuditEntry> log = root.getAuditLog();
if (log == null || log.isEmpty()) {
sb.append("(keine Einträge)\n");
} else {
for (InvoiceAuditEntry entry : log) {
sb.append("- ");
sb.append(entry.getTimestamp() != null ? entry.getTimestamp().format(TS_FMT) : "-");
sb.append(" · ").append(entry.getAction());
sb.append(" · ").append(safe(entry.getUserDisplayName()));
if (entry.getReason() != null && !entry.getReason().isBlank()) {
sb.append("").append(entry.getReason());
}
if (entry.getResultingInvoiceNumber() != null) {
sb.append("").append(entry.getResultingInvoiceNumber());
}
sb.append("\n");
}
}
sb.append(
"\nHinweis: Dieses Paket dient der gemeinsamen Aufbewahrung von Original und Folgebelegen.\n");
sb.append("Die rechtliche Aufbewahrungspflicht liegt beim Aussteller (R-31/R-32).\n");
return sb.toString();
}
private String safe(String value) {
return value != null ? value : "";
}
private String sanitize(String input) {
if (input == null) {
return "Beleg";
}
return input.replaceAll("[^A-Za-z0-9._-]", "_");
}
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.votianlt.service;
/**
* Wird geworfen, wenn ein Statusübergang oder eine Änderung an einer Rechnung
* gegen die Regeln aus <code>invoices_rules.md</code> verstößt (z.B. R-03, R-08, R-11, R-35).
*
* Die Nachricht ist als Anwendertext formuliert und kann direkt in der UI angezeigt werden.
*/
public class InvoiceLifecycleException extends RuntimeException {
public InvoiceLifecycleException(String message) {
super(message);
}
public InvoiceLifecycleException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,536 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.model.invoices.PaymentStatus;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet den Lebenszyklus einer {@link CustomerInvoice} gemäß den Regeln aus
* <code>invoices_rules.md</code>.
*
* Phase 1 stellt die Status- und Audit-Mechanik bereit:
* <ul>
* <li>R-02/R-03: Entwürfe sind editier-/löschbar, finalisierte Belege nicht.</li>
* <li>R-07: Finalisierung markiert eine Rechnung als verbindlich.</li>
* <li>R-08/R-11: Verhindert Doppelvergabe der Rechnungsnummer und unsichtbare Änderungen.</li>
* <li>R-35: Direktes Löschen finalisierter Belege wird abgelehnt.</li>
* <li>R-36 bis R-39: Jede Statusänderung wird im Audit-Log protokolliert.</li>
* </ul>
*
* Korrektur-/Storno-Workflows folgen in Phase 2; entsprechende Hooks werden hier vorbereitet.
*/
@Service
public class InvoiceLifecycleService {
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleService.class);
private final CustomerInvoiceRepository invoiceRepository;
private final SecurityService securityService;
private final InvoiceComplianceValidator complianceValidator;
private final InvoiceNumberAuditService numberAuditService;
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
InvoiceComplianceValidator complianceValidator,
InvoiceNumberAuditService numberAuditService) {
this.invoiceRepository = invoiceRepository;
this.securityService = securityService;
this.complianceValidator = complianceValidator;
this.numberAuditService = numberAuditService;
}
/**
* Persistiert einen neu erzeugten Rechnungsentwurf (Status DRAFT).
*/
public CustomerInvoice createDraft(CustomerInvoice draft, String reason) {
if (draft == null) {
throw new IllegalArgumentException("Rechnungsentwurf darf nicht null sein.");
}
draft.setStatus(InvoiceStatus.DRAFT);
if (draft.getType() == null) {
draft.setType(InvoiceType.INVOICE);
}
draft.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
return invoiceRepository.save(draft);
}
/**
* Erzeugt eine Rechnung und finalisiert sie unmittelbar (Status ISSUED).
* Wird vom bestehenden Erstell-Flow verwendet, der Vorschau und Speichern in
* einem Schritt vereint (R-06/R-07).
*/
public CustomerInvoice createAndIssue(CustomerInvoice invoice, String reason) {
if (invoice == null) {
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
}
if (invoice.getType() == null) {
invoice.setType(InvoiceType.INVOICE);
}
complianceValidator.validateForIssuance(invoice);
ensureInvoiceNumberUnique(invoice);
invoice.setStatus(InvoiceStatus.ISSUED);
invoice.setIssuedAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
CustomerInvoice saved = invoiceRepository.save(invoice);
numberAuditService.markUsed(saved);
return saved;
}
/**
* Speichert Änderungen an einem bestehenden Entwurf (R-02/R-05).
* Lehnt Änderungen an finalisierten Rechnungen ab (R-03/R-08).
*/
public CustomerInvoice updateDraft(CustomerInvoice draft, String reason) {
if (draft == null || draft.getId() == null) {
throw new IllegalArgumentException("Bestehender Entwurf erwartet.");
}
CustomerInvoice persisted = invoiceRepository.findById(draft.getId())
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + draft.getId()));
if (!persisted.getStatus().isMutable()) {
throw new InvoiceLifecycleException(
"Diese Rechnung ist bereits ausgestellt und kann nicht mehr direkt bearbeitet werden. "
+ "Bitte erstellen Sie eine Berichtigung oder ein Storno.");
}
draft.setStatus(InvoiceStatus.DRAFT);
draft.setAuditLog(persisted.getAuditLog());
draft.addAuditEntry(audit(InvoiceAuditAction.UPDATED_DRAFT, reason));
return invoiceRepository.save(draft);
}
/**
* Finalisiert einen Entwurf (Status ISSUED). Stellt sicher, dass die Rechnungsnummer
* eindeutig ist (R-11) und protokolliert den Wechsel.
*/
public CustomerInvoice issue(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.ISSUED || invoice.getStatus() == InvoiceStatus.SENT) {
return invoice;
}
if (invoice.getStatus() != InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Nur Entwürfe können ausgestellt werden. Aktueller Status: " + invoice.getStatus());
}
complianceValidator.validateForIssuance(invoice);
ensureInvoiceNumberUnique(invoice);
invoice.setStatus(InvoiceStatus.ISSUED);
invoice.setIssuedAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
CustomerInvoice saved = invoiceRepository.save(invoice);
numberAuditService.markUsed(saved);
return saved;
}
/**
* Markiert eine ausgestellte Rechnung als versendet (R-08).
*/
public CustomerInvoice markAsSent(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Eine Rechnung muss vor dem Versand zunächst ausgestellt werden.");
}
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException("Eine stornierte Rechnung kann nicht mehr versendet werden.");
}
invoice.setStatus(InvoiceStatus.SENT);
invoice.setSentAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.SENT, reason));
return invoiceRepository.save(invoice);
}
/**
* Erzeugt einen Stornobeleg zu einer bereits ausgestellten Rechnung (R-17 bis R-22).
*
* Der Stornobeleg ist ein eigenständiger Beleg vom Typ {@link InvoiceType#CANCELLATION}
* mit eigener (neuer) Rechnungsnummer. Die Originalrechnung wird auf
* {@link InvoiceStatus#CANCELLED} gesetzt; Original und Storno sind über
* {@code originalInvoiceId} bzw. {@code cancellationInvoiceId} verlinkt.
*
* @param originalId ID der zu stornierenden Rechnung
* @param cancellationNumber neue, fortlaufende Rechnungsnummer für den Stornobeleg
* @param cancellationDate Belegdatum des Stornos
* @param pdfData generiertes PDF des Stornobelegs
* @param reason nachvollziehbarer Grund (R-36)
*/
public CustomerInvoice cancel(String originalId, String cancellationNumber, LocalDate cancellationDate,
byte[] pdfData, String reason) {
CustomerInvoice original = requireInvoice(originalId);
if (original.getType() != InvoiceType.INVOICE) {
throw new InvoiceLifecycleException(
"Nur reguläre Rechnungen können storniert werden. Belegtyp: " + original.getType());
}
if (original.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException("Diese Rechnung ist bereits storniert.");
}
if (original.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Ein Entwurf wird nicht storniert, sondern gelöscht oder bearbeitet.");
}
if (cancellationNumber == null || cancellationNumber.isBlank()) {
throw new InvoiceLifecycleException("Stornobeleg benötigt eine fortlaufende Belegnummer.");
}
CustomerInvoice cancellation = new CustomerInvoice();
cancellation.setType(InvoiceType.CANCELLATION);
cancellation.setStatus(InvoiceStatus.ISSUED);
cancellation.setInvoiceNumber(cancellationNumber);
cancellation.setInvoiceDate(cancellationDate != null ? cancellationDate : LocalDate.now());
cancellation.setIssuedAt(LocalDateTime.now());
cancellation.setUserId(original.getUserId());
cancellation.setJobId(original.getJobId());
cancellation.setOriginalInvoiceId(original.getId());
cancellation.setOriginalInvoiceNumber(original.getInvoiceNumber());
cancellation.setOriginalInvoiceDate(original.getInvoiceDate());
// Empfänger-/Sender-Daten übernehmen für vollständige Pflichtangaben
copyParties(original, cancellation);
cancellation.setItems(original.getItems());
// Beträge negieren Storno bucht den Originalbetrag aus
cancellation.setNetAmount(negate(original.getNetAmount()));
cancellation.setVatRate(original.getVatRate());
cancellation.setVatAmount(negate(original.getVatAmount()));
cancellation.setTotalAmount(negate(original.getTotalAmount()));
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
cancellation.setPdfData(pdfData);
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
cancellation.addAuditEntry(issuedEntry);
ensureInvoiceNumberUnique(cancellation);
CustomerInvoice savedCancellation = invoiceRepository.save(cancellation);
numberAuditService.markUsed(savedCancellation);
// Original markieren und verlinken
original.setStatus(InvoiceStatus.CANCELLED);
original.setCancelledAt(LocalDateTime.now());
original.setCancellationInvoiceId(savedCancellation.getId());
// Wenn die Originalrechnung bereits (teil-)bezahlt war, entsteht ein Erstattungsanspruch (R-26)
BigDecimal paid = original.getPaidAmount() != null ? original.getPaidAmount() : BigDecimal.ZERO;
original.setPaymentStatus(computePaymentStatus(original, paid));
InvoiceAuditEntry cancelEntry = audit(InvoiceAuditAction.CANCELLED, reason);
cancelEntry.setResultingInvoiceId(savedCancellation.getId());
cancelEntry.setResultingInvoiceNumber(savedCancellation.getInvoiceNumber());
original.addAuditEntry(cancelEntry);
invoiceRepository.save(original);
log.info("Rechnung {} storniert durch Beleg {}.", original.getInvoiceNumber(),
savedCancellation.getInvoiceNumber());
return savedCancellation;
}
/**
* Erzeugt einen Berichtigungsbeleg zu einer bereits ausgestellten Rechnung (R-12 bis R-16).
*
* Eine Berichtigung adressiert formale Fehler (Adresse, Leistungsdatum, Pflichtangabe).
* Sie ersetzt die Originalrechnung nicht, sondern verweist auf sie. Originalrechnung
* wechselt in den Status {@link InvoiceStatus#CORRECTED} und hält eine Referenz auf den
* Berichtigungsbeleg.
*
* @param originalId ID der zu berichtigenden Rechnung
* @param correctionNumber fortlaufende Belegnummer für den Berichtigungsbeleg
* @param correctionDate Belegdatum
* @param pdfData generiertes PDF des Berichtigungsbelegs
* @param correctedFields Beschreibung der ergänzten/korrigierten Angaben (R-14)
* @param reason Grund der Berichtigung (R-36)
*/
public CustomerInvoice correct(String originalId, String correctionNumber, LocalDate correctionDate,
byte[] pdfData, String correctedFields, String reason) {
CustomerInvoice original = requireInvoice(originalId);
if (original.getType() != InvoiceType.INVOICE) {
throw new InvoiceLifecycleException(
"Nur reguläre Rechnungen können berichtigt werden. Belegtyp: " + original.getType());
}
if (original.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Ein Entwurf wird nicht berichtigt, sondern direkt bearbeitet.");
}
if (original.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException(
"Eine bereits stornierte Rechnung kann nicht berichtigt werden. Erstellen Sie eine neue Rechnung.");
}
if (correctionNumber == null || correctionNumber.isBlank()) {
throw new InvoiceLifecycleException("Berichtigungsbeleg benötigt eine fortlaufende Belegnummer.");
}
CustomerInvoice correction = new CustomerInvoice();
correction.setType(InvoiceType.CORRECTION);
correction.setStatus(InvoiceStatus.ISSUED);
correction.setInvoiceNumber(correctionNumber);
correction.setInvoiceDate(correctionDate != null ? correctionDate : LocalDate.now());
correction.setIssuedAt(LocalDateTime.now());
correction.setUserId(original.getUserId());
correction.setJobId(original.getJobId());
correction.setOriginalInvoiceId(original.getId());
correction.setOriginalInvoiceNumber(original.getInvoiceNumber());
correction.setOriginalInvoiceDate(original.getInvoiceDate());
copyParties(original, correction);
correction.setItems(original.getItems());
correction.setNetAmount(original.getNetAmount());
correction.setVatRate(original.getVatRate());
correction.setVatAmount(original.getVatAmount());
correction.setTotalAmount(original.getTotalAmount());
String descriptionPrefix = "Berichtigung zu Rechnung " + original.getInvoiceNumber();
correction.setDescription(
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
: descriptionPrefix + "" + correctedFields);
correction.setPdfData(pdfData);
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(correctionNumber);
correction.addAuditEntry(issuedEntry);
ensureInvoiceNumberUnique(correction);
CustomerInvoice savedCorrection = invoiceRepository.save(correction);
numberAuditService.markUsed(savedCorrection);
original.setStatus(InvoiceStatus.CORRECTED);
original.setCorrectionInvoiceId(savedCorrection.getId());
InvoiceAuditEntry correctEntry = audit(InvoiceAuditAction.CORRECTED, reason);
correctEntry.setResultingInvoiceId(savedCorrection.getId());
correctEntry.setResultingInvoiceNumber(savedCorrection.getInvoiceNumber());
original.addAuditEntry(correctEntry);
invoiceRepository.save(original);
log.info("Rechnung {} berichtigt durch Beleg {}.", original.getInvoiceNumber(),
savedCorrection.getInvoiceNumber());
return savedCorrection;
}
/**
* Speichert eine vollständig neue Ersatzrechnung nach einem Storno (R-20 bis R-22).
* Die neue Rechnung erhält eine eigene Rechnungsnummer und referenziert die stornierte
* Originalrechnung, sodass die Verkettung Original → Storno → Ersatzrechnung erhalten bleibt.
*/
public CustomerInvoice createReplacementInvoice(String cancelledOriginalId, CustomerInvoice replacement,
String reason) {
CustomerInvoice cancelledOriginal = requireInvoice(cancelledOriginalId);
if (cancelledOriginal.getStatus() != InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException(
"Eine Ersatzrechnung kann nur zu einer stornierten Rechnung erstellt werden.");
}
if (replacement == null) {
throw new IllegalArgumentException("Ersatzrechnung darf nicht null sein.");
}
replacement.setType(InvoiceType.INVOICE);
replacement.setOriginalInvoiceId(cancelledOriginal.getId());
replacement.setOriginalInvoiceNumber(cancelledOriginal.getInvoiceNumber());
replacement.setOriginalInvoiceDate(cancelledOriginal.getInvoiceDate());
CustomerInvoice savedReplacement = createAndIssue(replacement,
reason != null ? reason : "Ersatzrechnung nach Storno");
cancelledOriginal.setReplacementInvoiceId(savedReplacement.getId());
InvoiceAuditEntry replaceEntry = audit(InvoiceAuditAction.REPLACED, reason);
replaceEntry.setResultingInvoiceId(savedReplacement.getId());
replaceEntry.setResultingInvoiceNumber(savedReplacement.getInvoiceNumber());
cancelledOriginal.addAuditEntry(replaceEntry);
invoiceRepository.save(cancelledOriginal);
return savedReplacement;
}
/**
* Liefert die Folgebelege (Storno, Berichtigung, Ersatzrechnung) zu einer Rechnung.
*/
public List<CustomerInvoice> findRelatedDocuments(String originalInvoiceId) {
if (originalInvoiceId == null) {
return List.of();
}
return invoiceRepository.findByOriginalInvoiceId(originalInvoiceId);
}
/**
* Erfasst eine Zahlung zur Rechnung (R-23 bis R-26).
*
* Eine Zahlung wird ausschließlich erfasst die Rechnungsdaten selbst werden nicht
* verändert (R-25). Der Zahlungsstatus ergibt sich aus dem Verhältnis Zahlung zu
* Bruttobetrag der Rechnung.
*
* @param invoiceId Rechnung, auf die gebucht wird
* @param amount erfasster Zahlbetrag (kann negativ sein für Korrekturen)
* @param paymentReference frei wählbarer Referenztext (z.B. Kontoauszug, Beleg)
* @param reason erläuternder Grund für das Audit-Log
*/
public CustomerInvoice registerPayment(String invoiceId, BigDecimal amount, String paymentReference,
String reason) {
if (amount == null) {
throw new IllegalArgumentException("Zahlbetrag erforderlich.");
}
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Auf Entwürfen können keine Zahlungen erfasst werden. Bitte zuerst ausstellen.");
}
if (invoice.getType() == InvoiceType.CORRECTION) {
throw new InvoiceLifecycleException(
"Auf Berichtigungsbelegen werden keine Zahlungen erfasst buchen Sie auf der Originalrechnung.");
}
BigDecimal previous = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
BigDecimal newPaid = previous.add(amount);
invoice.setPaidAmount(newPaid);
invoice.setLastPaymentAt(LocalDateTime.now());
invoice.setPaymentStatus(computePaymentStatus(invoice, newPaid));
StringBuilder logReason = new StringBuilder();
logReason.append("Zahlung erfasst: ").append(amount.toPlainString());
if (paymentReference != null && !paymentReference.isBlank()) {
logReason.append(" (Referenz: ").append(paymentReference).append(")");
}
if (reason != null && !reason.isBlank()) {
logReason.append(" ").append(reason);
}
invoice.addAuditEntry(audit(InvoiceAuditAction.PAYMENT_RECORDED, logReason.toString()));
return invoiceRepository.save(invoice);
}
/**
* Liefert den noch offenen Betrag einer Rechnung. Negative Werte zeigen eine
* Überzahlung bzw. einen Erstattungsanspruch (R-26).
*/
public BigDecimal computeOutstandingAmount(CustomerInvoice invoice) {
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
BigDecimal paid = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
return total.subtract(paid);
}
private PaymentStatus computePaymentStatus(CustomerInvoice invoice, BigDecimal paid) {
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
return paid.signum() == 0 ? PaymentStatus.UNPAID : PaymentStatus.REFUND_DUE;
}
int cmp = paid.compareTo(total);
if (paid.signum() == 0) {
return PaymentStatus.UNPAID;
}
if (cmp == 0) {
return PaymentStatus.PAID;
}
if (cmp < 0) {
return PaymentStatus.PARTIALLY_PAID;
}
return PaymentStatus.OVERPAID;
}
/**
* Löscht einen Entwurf. Finalisierte Rechnungen dürfen nicht gelöscht werden (R-35).
*/
public void deleteDraft(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (!invoice.getStatus().isMutable()) {
throw new InvoiceLifecycleException(
"Finalisierte Rechnungen können nicht gelöscht werden. "
+ "Bitte führen Sie stattdessen ein Storno oder eine Berichtigung durch.");
}
invoice.addAuditEntry(audit(InvoiceAuditAction.DELETED_DRAFT, reason));
log.info("Rechnungsentwurf {} wird gelöscht: {}", invoiceId, reason);
invoiceRepository.delete(invoice);
// Wenn dem Entwurf bereits eine Nummer reserviert wurde, dokumentiert das
// Verwerfen jetzt die Lücke im Nummernkreis — sonst bliebe die Reservierung
// als unerklärter „RESERVED"-Eintrag im Audit hängen.
if (invoice.getInvoiceNumber() != null && !invoice.getInvoiceNumber().isBlank()
&& invoice.getUserId() != null) {
try {
ObjectId userId = new ObjectId(invoice.getUserId());
String voidReason = (reason != null && !reason.isBlank())
? reason
: "Rechnungsentwurf gelöscht";
numberAuditService.markVoided(userId, invoice.getInvoiceNumber(), voidReason);
} catch (IllegalArgumentException | InvoiceLifecycleException ex) {
// Keine Reservierung vorhanden oder UserId nicht parsebar — Lücken-Detektor
// erfasst das später; das Löschen selbst soll nicht blockiert werden.
log.debug("VOIDED-Markierung beim Löschen des Entwurfs übersprungen: {}", ex.getMessage());
}
}
}
public Optional<CustomerInvoice> findById(String invoiceId) {
return invoiceRepository.findById(invoiceId);
}
private CustomerInvoice requireInvoice(String invoiceId) {
if (invoiceId == null || invoiceId.isBlank()) {
throw new IllegalArgumentException("Rechnungs-ID erforderlich.");
}
return invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId));
}
private void ensureInvoiceNumberUnique(CustomerInvoice invoice) {
String number = invoice.getInvoiceNumber();
if (number == null || number.isBlank()) {
throw new InvoiceLifecycleException("Eine ausgestellte Rechnung benötigt eine Rechnungsnummer.");
}
invoiceRepository.findByInvoiceNumberAndStatusNot(number, InvoiceStatus.CANCELLED).ifPresent(existing -> {
if (!existing.getId().equals(invoice.getId())) {
throw new InvoiceLifecycleException(
"Rechnungsnummer " + number + " wird bereits von einer aktiven Rechnung verwendet.");
}
});
}
private InvoiceAuditEntry audit(InvoiceAuditAction action, String reason) {
String userId = null;
String displayName = "system";
try {
User user = securityService.getCurrentDatabaseUser();
userId = user.getId() != null ? user.getId().toHexString() : null;
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
displayName = composed.isBlank() ? safe(user.getEmail()) : composed;
} catch (Exception ignored) {
// Audit funktioniert auch außerhalb einer Vaadin-Session (z.B. Migration).
}
return new InvoiceAuditEntry(action, userId, displayName, reason);
}
private String safe(String value) {
return value != null ? value : "";
}
private void copyParties(CustomerInvoice source, CustomerInvoice target) {
target.setSenderName(source.getSenderName());
target.setSenderAddress(source.getSenderAddress());
target.setSenderPostcode(source.getSenderPostcode());
target.setSenderCity(source.getSenderCity());
target.setSenderCountry(source.getSenderCountry());
target.setSenderTaxNumber(source.getSenderTaxNumber());
target.setSenderVatId(source.getSenderVatId());
target.setSenderPhone(source.getSenderPhone());
target.setSenderEmail(source.getSenderEmail());
target.setSenderWebsite(source.getSenderWebsite());
target.setRecipientName(source.getRecipientName());
target.setRecipientCompany(source.getRecipientCompany());
target.setRecipientAddress(source.getRecipientAddress());
target.setRecipientPostcode(source.getRecipientPostcode());
target.setRecipientCity(source.getRecipientCity());
target.setRecipientCountry(source.getRecipientCountry());
target.setRecipientVatId(source.getRecipientVatId());
}
private BigDecimal negate(BigDecimal value) {
return value != null ? value.negate() : null;
}
}

View File

@@ -0,0 +1,166 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet das Audit der vergebenen Rechnungsnummern. Jede Vergabe aus dem
* Counter wird als RESERVED protokolliert; dieser Service vollzieht die
* Status-Übergänge nach (USED beim Festschreiben, VOIDED beim Verwerfen)
* und liefert Lücken-Reports für die Betriebsprüfung.
*
* Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende
* Rechnungsnummer; lückenhafte Nummernkreise sind nur zulässig, wenn jede
* fehlende Nummer dokumentiert erklärt werden kann (GoBD).
*
* Fehler beim Audit-Schreiben werden bewusst nicht propagiert: Die
* fachliche Operation (Rechnung festschreiben) hat Vorrang. Verlorene
* USED-Markierungen sind über den Lücken-Report nachträglich rekonstruierbar.
*/
@Service
public class InvoiceNumberAuditService {
private static final Logger log = LoggerFactory.getLogger(InvoiceNumberAuditService.class);
private final InvoiceNumberReservationRepository repository;
public InvoiceNumberAuditService(InvoiceNumberReservationRepository repository) {
this.repository = repository;
}
/**
* Markiert die Reservierung der übergebenen Rechnung als USED.
* Wird vom Lifecycle nach erfolgreichem Festschreiben aufgerufen.
*/
public void markUsed(CustomerInvoice invoice) {
if (invoice == null || invoice.getInvoiceNumber() == null || invoice.getUserId() == null) {
return;
}
ObjectId userId = parseUserId(invoice.getUserId());
if (userId == null) {
return;
}
try {
Optional<InvoiceNumberReservation> existing = repository.findByUserIdAndNumber(userId,
invoice.getInvoiceNumber());
InvoiceNumberReservation reservation = existing.orElseGet(() -> bootstrapReservation(userId, invoice));
reservation.setStatus(InvoiceNumberReservationStatus.USED);
reservation.setInvoiceId(invoice.getId());
reservation.setUsedAt(Instant.now());
repository.save(reservation);
} catch (Exception ex) {
log.warn("USED-Markierung für Nummer {} (Rechnung {}) fehlgeschlagen: {}",
invoice.getInvoiceNumber(), invoice.getId(), ex.getMessage(), ex);
}
}
/**
* Markiert die zur übergebenen Nummer gehörende Reservierung als VOIDED.
* Pflichtfeld {@code reason} dokumentiert die Erklärung der Lücke.
*/
public void markVoided(ObjectId userId, String number, String reason) {
if (userId == null || number == null || number.isBlank()) {
throw new IllegalArgumentException("userId und number sind Pflichtparameter.");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("Grund (reason) ist Pflicht beim Verwerfen einer Reservierung.");
}
InvoiceNumberReservation reservation = repository.findByUserIdAndNumber(userId, number)
.orElseThrow(() -> new InvoiceLifecycleException(
"Keine Reservierung für Nummer " + number + " gefunden."));
if (reservation.getStatus() == InvoiceNumberReservationStatus.USED) {
throw new InvoiceLifecycleException(
"Nummer " + number + " ist bereits einer ausgestellten Rechnung zugeordnet "
+ "und kann nicht verworfen werden.");
}
reservation.setStatus(InvoiceNumberReservationStatus.VOIDED);
reservation.setVoidReason(reason);
reservation.setVoidedAt(Instant.now());
repository.save(reservation);
}
/**
* Liefert alle Reservierungen eines Nutzers in Sequenzreihenfolge.
* Basis für vollständige Audit-Reports.
*/
public List<InvoiceNumberReservation> findAll(ObjectId userId) {
return repository.findByUserIdOrderBySequenceAsc(userId);
}
/**
* Liefert nur die noch nicht verwendeten Reservierungen eines Nutzers
* (Status RESERVED oder VOIDED). Im Idealfall ist diese Liste leer oder
* enthält ausschließlich VOIDED-Einträge mit dokumentiertem Grund.
* Verbleibende RESERVED-Einträge nach abgeschlossenen Vorgängen sind
* unerklärte Lücken und sollten in der UI hervorgehoben werden.
*/
public List<InvoiceNumberReservation> findUnused(ObjectId userId) {
List<InvoiceNumberReservation> all = repository.findByUserIdOrderBySequenceAsc(userId);
return all.stream()
.filter(r -> r.getStatus() != InvoiceNumberReservationStatus.USED)
.toList();
}
/**
* Erzeugt einen Bootstrap-Eintrag für Rechnungen, die ohne vorausgegangene
* Reservierung festgeschrieben wurden — z.B. Bestandsdaten aus der Zeit
* vor Einführung des Reservierungs-Audits oder Storno-/Korrekturbelege,
* deren Nummer extern übergeben wurde. Status wird direkt auf USED gesetzt.
*/
private InvoiceNumberReservation bootstrapReservation(ObjectId userId, CustomerInvoice invoice) {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(invoice.getInvoiceNumber());
reservation.setSequence(extractSequence(invoice.getInvoiceNumber()));
reservation.setReservedAt(Instant.now());
reservation.setReservedBy("system (bootstrap)");
return reservation;
}
/**
* Best-effort-Extraktion der numerischen Sequenz aus einer formatierten
* Rechnungsnummer (z.B. „RE-2026-000123" → 123). Liefert -1, wenn keine
* trailing-Ziffern vorhanden sind — dann ist die Nummer für die
* Lücken-Sortierung ungeeignet, aber der Audit-Eintrag bleibt erhalten.
*/
private long extractSequence(String number) {
if (number == null) {
return -1L;
}
int end = number.length();
int start = end;
while (start > 0 && Character.isDigit(number.charAt(start - 1))) {
start--;
}
if (start == end) {
return -1L;
}
try {
return Long.parseLong(number.substring(start, end));
} catch (NumberFormatException ex) {
return -1L;
}
}
private ObjectId parseUserId(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return new ObjectId(value);
} catch (IllegalArgumentException ex) {
log.warn("UserId '{}' ist keine gültige ObjectId — Audit übersprungen.", value);
return null;
}
}
}

View File

@@ -0,0 +1,140 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.security.InvoiceRoles;
import de.assecutor.votianlt.security.SecurityService;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* Berechtigungs-Checks für Rechnungsaktionen gemäß R-40 bis R-42.
*
* Backwards-compat: ein Nutzer mit der bestehenden {@code USER}- oder {@code ADMIN}-Rolle,
* der keine der spezialisierten Invoice-Rollen besitzt, hat weiterhin volle Berechtigung
* — andernfalls würden alle bestehenden Installationen sofort handlungsunfähig.
*
* Sobald ein Nutzer mindestens eine {@code INVOICE_*}-Rolle hat, gelten die feingranularen
* Regeln und nur die explizit zugewiesenen Aktionen sind erlaubt.
*/
@Service
public class InvoicePermissionService {
private static final String ROLE_ADMIN = "ADMIN";
private final SecurityService securityService;
public InvoicePermissionService(SecurityService securityService) {
this.securityService = securityService;
}
public boolean canCreateOrIssue(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR) || isUnscoped(user);
}
public boolean canMarkAsSent(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR, InvoiceRoles.REVIEWER, InvoiceRoles.APPROVER)
|| isUnscoped(user);
}
public boolean canCancel(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public boolean canCorrect(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER, InvoiceRoles.REVIEWER) || isUnscoped(user);
}
public boolean canRecordPayment(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public void requireCreate(User user) {
if (!canCreateOrIssue(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen zu erstellen oder auszustellen.");
}
}
public void requireSend(User user) {
if (!canMarkAsSent(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen als versendet zu markieren.");
}
}
public void requireCancel(User user) {
if (!canCancel(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen zu stornieren.");
}
}
public void requireCorrect(User user) {
if (!canCorrect(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Berichtigungsbelege zu erstellen.");
}
}
public void requirePayment(User user) {
if (!canRecordPayment(user)) {
throw new InvoiceLifecycleException("Sie haben keine Berechtigung, Zahlungen zu erfassen.");
}
}
/**
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
*/
public boolean isOwnerOrAdmin(User user, CustomerInvoice invoice) {
if (user == null) {
return false;
}
if (isAdmin(user)) {
return true;
}
if (invoice == null || invoice.getUserId() == null || user.getId() == null) {
return false;
}
return invoice.getUserId().equals(user.getId().toHexString());
}
private boolean hasAnyInvoiceRole(User user, String... roles) {
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> userRoles = user.getRoles();
if (userRoles.contains(ROLE_ADMIN)) {
return true;
}
for (String role : roles) {
if (userRoles.contains(role)) {
return true;
}
}
return false;
}
/**
* {@code true}, wenn dem Nutzer keine spezialisierte Invoice-Rolle zugewiesen ist —
* dann gilt das alte Pauschalrecht der USER-Rolle (Backwards-Compat).
*/
private boolean isUnscoped(User user) {
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> roles = user.getRoles();
return roles.stream().noneMatch(r -> r.startsWith("INVOICE_"));
}
private boolean isAdmin(User user) {
return user != null && user.getRoles() != null && user.getRoles().contains(ROLE_ADMIN);
}
/** Bequemer Lookup für die UI. */
public User currentUser() {
return securityService.getCurrentDatabaseUser();
}
}

View File

@@ -242,7 +242,7 @@ page.title.appuser.create=Neuen App-Nutzer anlegen
page.title.messages=Nachrichten page.title.messages=Nachrichten
page.title.register=Bei VotianLT registrieren page.title.register=Bei VotianLT registrieren
page.title.customers=Kunden page.title.customers=Kunden
page.title.customer.edit=Kunde bearbeiten page.title.customer.edit=Adresse bearbeiten
page.title.verwaltung=Verwaltung page.title.verwaltung=Verwaltung
page.title.company.create=Neue Firma anlegen page.title.company.create=Neue Firma anlegen
page.title.imprint=Impressum page.title.imprint=Impressum
@@ -339,13 +339,13 @@ customers.column.street=Straße
customers.column.city=Ort customers.column.city=Ort
# Edit Customer # Edit Customer
editcustomer.title=Kunde bearbeiten editcustomer.title=Adresse bearbeiten
editcustomer.notification.notfound=Kunde nicht gefunden editcustomer.notification.notfound=Adresse nicht gefunden
editcustomer.notification.invalid.id=Ungültige Kunden-ID editcustomer.notification.invalid.id=Ungültige Adress-ID
editcustomer.notification.saved=Kunde erfolgreich gespeichert editcustomer.notification.saved=Adresse erfolgreich gespeichert
editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
editcustomer.notification.deleted=Kunde erfolgreich gelöscht editcustomer.notification.deleted=Adresse erfolgreich gelöscht
editcustomer.dialog.delete.text=Möchten Sie diesen Kunden wirklich löschen? editcustomer.dialog.delete.text=Möchten Sie diese Adresse wirklich löschen?
editcustomer.dialog.delete.confirm=Löschen editcustomer.dialog.delete.confirm=Löschen
# Add Customer # Add Customer
@@ -429,9 +429,9 @@ messages.sender.unknown=Unbekannter Absender
# Add Job # Add Job
addjob.title=Neuen Auftrag anlegen addjob.title=Neuen Auftrag anlegen
addjob.customer.label=Kunde addjob.customer.label=Auftraggeber
addjob.customer.placeholder=Kunde auswählen addjob.customer.placeholder=Auftraggeber auswählen
addjob.customer.unnamed=Unbenannter Kunde addjob.customer.unnamed=Unbenannter Auftraggeber
addjob.button.clearfields=Felder leeren addjob.button.clearfields=Felder leeren
addjob.button.submit=Auftrag anlegen addjob.button.submit=Auftrag anlegen
addjob.address.salutation=Anrede addjob.address.salutation=Anrede
@@ -440,6 +440,10 @@ addjob.salutation.mr=Herr
addjob.salutation.ms=Frau addjob.salutation.ms=Frau
addjob.salutation.other=Divers addjob.salutation.other=Divers
addjob.address.company.placeholder=Firma eingeben addjob.address.company.placeholder=Firma eingeben
addjob.address.pickup.label=Abholadresse
addjob.address.pickup.placeholder=Abholadresse auswählen oder eingeben
addjob.address.delivery.label=Lieferadresse
addjob.address.delivery.placeholder=Lieferadresse auswählen oder eingeben
addjob.address.street.placeholder=Straße eingeben addjob.address.street.placeholder=Straße eingeben
addjob.address.housenumber=Hausnummer addjob.address.housenumber=Hausnummer
addjob.address.addition.placeholder=Adresszusatz addjob.address.addition.placeholder=Adresszusatz
@@ -460,6 +464,8 @@ addjob.station.max.reached=Maximale Anzahl von 25 Lieferstationen erreicht
addjob.station.unused=Nicht genutzt addjob.station.unused=Nicht genutzt
addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt. addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt.
addjob.tab.addresses=Auftraggeber & Adressen addjob.tab.addresses=Auftraggeber & Adressen
addjob.tab.pickup.address=Auftraggeber & Abholadresse
addjob.tab.delivery.address=Lieferadresse
addjob.tab.appointments=Termine & Verarbeitung addjob.tab.appointments=Termine & Verarbeitung
addjob.tab.cargo=Fracht addjob.tab.cargo=Fracht
addjob.tab.tasks=Aufgaben addjob.tab.tasks=Aufgaben
@@ -621,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Bitte geben Sie eine Begründun
jobsummary.dialog.manualcomplete.cancel=Abbrechen jobsummary.dialog.manualcomplete.cancel=Abbrechen
jobsummary.dialog.manualcomplete.confirm=Akzeptiert jobsummary.dialog.manualcomplete.confirm=Akzeptiert
jobsummary.history.manualcomplete.reason=Manuell beendet jobsummary.history.manualcomplete.reason=Manuell beendet
jobmanualcomplete.route.hours=Stunden
jobmanualcomplete.route.minutes=Minuten
jobmanualcomplete.route.manual.hint=Keine Routendaten vorhanden bitte Entfernung und Dauer manuell erfassen.
# Jobs # Jobs
jobs.title=Aufträge jobs.title=Aufträge
@@ -697,6 +706,72 @@ invoices.column.amount=Betrag
invoices.column.description=Beschreibung invoices.column.description=Beschreibung
invoices.empty=Es wurden noch keine Rechnungen erstellt. invoices.empty=Es wurden noch keine Rechnungen erstellt.
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert. invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert.
invoices.notification.pdf.error=Die PDF-Anzeige ist fehlgeschlagen: {0}
invoices.column.status=Status
invoices.column.type=Typ
invoices.column.actions=Aktionen
invoices.disclaimer=Hinweis: Die rechtliche Aufbewahrungspflicht liegt beim Aussteller. Eine bereits ausgestellte Rechnung wird nicht überschrieben — Korrekturen erfolgen über Berichtigung oder Stornorechnung mit eindeutigem Bezug.
invoices.status.draft=Entwurf
invoices.status.issued=Ausgestellt
invoices.status.sent=Versendet
invoices.status.cancelled=Storniert
invoices.status.corrected=Berichtigt
invoices.type.invoice=Rechnung
invoices.type.cancellation=Stornorechnung
invoices.type.correction=Berichtigung
invoices.action.view=PDF anzeigen
invoices.action.history=Historie
invoices.action.marksent=Versendet markieren
invoices.action.correct=Berichtigen
invoices.action.cancel=Stornieren
invoices.notification.sent=Rechnung als versendet markiert.
invoices.notification.cancelled=Stornobeleg {0} erstellt.
invoices.notification.corrected=Berichtigungsbeleg {0} erstellt.
invoices.notification.error=Aktion fehlgeschlagen: {0}
invoices.cancel.title=Rechnung {0} stornieren
invoices.cancel.hint=Die Originalrechnung bleibt unverändert sichtbar. Es wird ein eigenständiger Stornobeleg mit eigener Belegnummer erstellt, der die Originalrechnung eindeutig referenziert.
invoices.cancel.reason=Grund der Stornierung
invoices.cancel.reason.required=Bitte einen Grund angeben.
invoices.cancel.confirm=Stornobeleg erstellen
invoices.correct.title=Rechnung {0} berichtigen
invoices.correct.hint=Eine Berichtigung dient ausschließlich der Korrektur formaler Fehler (z.B. Adresse, Leistungsdatum). Die Originalrechnung bleibt sichtbar; der Berichtigungsbeleg verweist eindeutig auf sie.
invoices.correct.fields=Berichtigte Angaben
invoices.correct.fields.helper=Beschreiben Sie, welche Angaben ergänzt oder ersetzt werden.
invoices.correct.fields.required=Bitte die berichtigten Angaben beschreiben.
invoices.correct.reason=Grund der Berichtigung
invoices.correct.confirm=Berichtigung erstellen
invoices.history.title=Historie zu Rechnung {0}
invoices.history.log=Änderungsprotokoll
invoices.history.empty=Keine Einträge vorhanden.
invoices.history.original=Original­rechnung
invoices.history.cancellation=Stornobeleg
invoices.history.correction=Berichtigungsbeleg
invoices.history.replacement=Ersatzrechnung
invoices.audit.action.created_draft=Entwurf erstellt
invoices.audit.action.updated_draft=Entwurf geändert
invoices.audit.action.issued=Ausgestellt
invoices.audit.action.sent=Versendet
invoices.audit.action.cancelled=Storniert
invoices.audit.action.corrected=Berichtigt
invoices.audit.action.replaced=Ersetzt durch neue Rechnung
invoices.audit.action.deleted_draft=Entwurf gelöscht
invoices.audit.action.payment_recorded=Zahlung erfasst
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
invoices.payment.unpaid=Offen
invoices.payment.partially_paid=Teilzahlung
invoices.payment.paid=Bezahlt
invoices.payment.overpaid=Überzahlung
invoices.payment.refund_due=Erstattung offen
invoices.action.payment=Zahlung erfassen
invoices.action.export=Exportieren
invoices.payment.title=Zahlung zu Rechnung {0}
invoices.payment.hint=Offener Restbetrag: {0}. Negative Beträge können zur Korrektur erfasst werden.
invoices.payment.amount=Zahlbetrag
invoices.payment.amount.required=Bitte einen Betrag (ungleich 0) angeben.
invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
invoices.payment.reason=Anmerkung
invoices.payment.confirm=Zahlung erfassen
invoices.notification.payment=Zahlung erfasst.
# My Invoices # My Invoices
myinvoices.title=Rechnungen myinvoices.title=Rechnungen

View File

@@ -377,9 +377,9 @@ messages.preview.image=Pilt
messages.preview.empty=Eelvaade puudub messages.preview.empty=Eelvaade puudub
messages.sender.unknown=Tundmatu saatja messages.sender.unknown=Tundmatu saatja
addjob.title=Uue tellimuse loomine addjob.title=Uue tellimuse loomine
addjob.customer.label=Klient addjob.customer.label=Tellija
addjob.customer.placeholder=Valige klient addjob.customer.placeholder=Vali tellija
addjob.customer.unnamed=Nimetu klient addjob.customer.unnamed=Nimetu tellija
addjob.button.clearfields=T\u00fchjenda v\u00e4ljad addjob.button.clearfields=T\u00fchjenda v\u00e4ljad
addjob.button.submit=Loo tellimus addjob.button.submit=Loo tellimus
addjob.address.salutation=P\u00f6\u00f6rdumine addjob.address.salutation=P\u00f6\u00f6rdumine
@@ -388,6 +388,10 @@ addjob.salutation.mr=Hr
addjob.salutation.ms=Pr addjob.salutation.ms=Pr
addjob.salutation.other=Muu addjob.salutation.other=Muu
addjob.address.company.placeholder=Sisestage ettev\u00f5te addjob.address.company.placeholder=Sisestage ettev\u00f5te
addjob.address.pickup.label=Pealekorje aadress
addjob.address.pickup.placeholder=Vali v\u00f5i sisesta pealekorje aadress
addjob.address.delivery.label=Kohaletoimetamise aadress
addjob.address.delivery.placeholder=Vali v\u00f5i sisesta kohaletoimetamise aadress
addjob.address.street.placeholder=Sisestage t\u00e4nav addjob.address.street.placeholder=Sisestage t\u00e4nav
addjob.address.housenumber=Majanumber addjob.address.housenumber=Majanumber
addjob.address.addition.placeholder=Aadressi t\u00e4iend addjob.address.addition.placeholder=Aadressi t\u00e4iend
@@ -408,6 +412,8 @@ addjob.station.max.reached=Maksimaalne arv 25 kohaletoimetamise jaama on saavuta
addjob.station.unused=Kasutamata addjob.station.unused=Kasutamata
addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades. addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades.
addjob.tab.addresses=Tellija ja aadressid addjob.tab.addresses=Tellija ja aadressid
addjob.tab.pickup.address=Tellija ja pealekorje aadress
addjob.tab.delivery.address=Kohaletoimetamise aadress
addjob.tab.appointments=Ajad ja t\u00f6\u00f6tlemine addjob.tab.appointments=Ajad ja t\u00f6\u00f6tlemine
addjob.tab.cargo=Veosed addjob.tab.cargo=Veosed
addjob.tab.tasks=\u00dclesanded addjob.tab.tasks=\u00dclesanded
@@ -567,6 +573,9 @@ jobsummary.dialog.manualcomplete.reason.required=Palun sisestage põhjendus
jobsummary.dialog.manualcomplete.cancel=Tühista jobsummary.dialog.manualcomplete.cancel=Tühista
jobsummary.dialog.manualcomplete.confirm=Nõustu jobsummary.dialog.manualcomplete.confirm=Nõustu
jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud
jobmanualcomplete.route.hours=Tunnid
jobmanualcomplete.route.minutes=Minutid
jobmanualcomplete.route.manual.hint=Marsruudiandmed puuduvad palun sisestage vahemaa ja kestus käsitsi.
jobs.title=Tellimused jobs.title=Tellimused
jobs.filter.search=Otsi jobs.filter.search=Otsi
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi... jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...

View File

@@ -429,9 +429,9 @@ messages.sender.unknown=Unknown Sender
# Add Job # Add Job
addjob.title=Create New Job addjob.title=Create New Job
addjob.customer.label=Customer addjob.customer.label=Principal
addjob.customer.placeholder=Select Customer addjob.customer.placeholder=Select principal
addjob.customer.unnamed=Unnamed Customer addjob.customer.unnamed=Unnamed principal
addjob.button.clearfields=Clear Fields addjob.button.clearfields=Clear Fields
addjob.button.submit=Create Job addjob.button.submit=Create Job
addjob.address.salutation=Salutation addjob.address.salutation=Salutation
@@ -440,6 +440,10 @@ addjob.salutation.mr=Mr
addjob.salutation.ms=Ms addjob.salutation.ms=Ms
addjob.salutation.other=Other addjob.salutation.other=Other
addjob.address.company.placeholder=Enter company addjob.address.company.placeholder=Enter company
addjob.address.pickup.label=Pickup address
addjob.address.pickup.placeholder=Select or enter pickup address
addjob.address.delivery.label=Delivery address
addjob.address.delivery.placeholder=Select or enter delivery address
addjob.address.street.placeholder=Enter street addjob.address.street.placeholder=Enter street
addjob.address.housenumber=House Number addjob.address.housenumber=House Number
addjob.address.addition.placeholder=Address suffix addjob.address.addition.placeholder=Address suffix
@@ -460,6 +464,8 @@ addjob.station.max.reached=Maximum number of 25 delivery stations reached
addjob.station.unused=Not used addjob.station.unused=Not used
addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations. addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations.
addjob.tab.addresses=Client & Addresses addjob.tab.addresses=Client & Addresses
addjob.tab.pickup.address=Principal & Pickup Address
addjob.tab.delivery.address=Delivery Address
addjob.tab.appointments=Appointments & Processing addjob.tab.appointments=Appointments & Processing
addjob.tab.cargo=Cargo addjob.tab.cargo=Cargo
addjob.tab.tasks=Tasks addjob.tab.tasks=Tasks
@@ -621,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Please enter a reason
jobsummary.dialog.manualcomplete.cancel=Cancel jobsummary.dialog.manualcomplete.cancel=Cancel
jobsummary.dialog.manualcomplete.confirm=Accept jobsummary.dialog.manualcomplete.confirm=Accept
jobsummary.history.manualcomplete.reason=Manually completed jobsummary.history.manualcomplete.reason=Manually completed
jobmanualcomplete.route.hours=Hours
jobmanualcomplete.route.minutes=Minutes
jobmanualcomplete.route.manual.hint=No route data available please enter distance and duration manually.
# Jobs # Jobs
jobs.title=Jobs jobs.title=Jobs
@@ -697,6 +706,72 @@ invoices.column.amount=Amount
invoices.column.description=Description invoices.column.description=Description
invoices.empty=No invoices have been created yet. invoices.empty=No invoices have been created yet.
invoices.notification.pdf.missing=No PDF is stored for this invoice. invoices.notification.pdf.missing=No PDF is stored for this invoice.
invoices.notification.pdf.error=Failed to display PDF: {0}
invoices.column.status=Status
invoices.column.type=Type
invoices.column.actions=Actions
invoices.disclaimer=Note: Legal retention obligations remain with the issuer. An already issued invoice is never overwritten — corrections are made through a correction document or cancellation invoice that explicitly references the original.
invoices.status.draft=Draft
invoices.status.issued=Issued
invoices.status.sent=Sent
invoices.status.cancelled=Cancelled
invoices.status.corrected=Corrected
invoices.type.invoice=Invoice
invoices.type.cancellation=Cancellation invoice
invoices.type.correction=Correction document
invoices.action.view=View PDF
invoices.action.history=History
invoices.action.marksent=Mark as sent
invoices.action.correct=Correct
invoices.action.cancel=Cancel
invoices.notification.sent=Invoice marked as sent.
invoices.notification.cancelled=Cancellation document {0} created.
invoices.notification.corrected=Correction document {0} created.
invoices.notification.error=Action failed: {0}
invoices.cancel.title=Cancel invoice {0}
invoices.cancel.hint=The original invoice remains visible. A separate cancellation document with its own number will be created, explicitly referencing the original invoice.
invoices.cancel.reason=Reason for cancellation
invoices.cancel.reason.required=Please provide a reason.
invoices.cancel.confirm=Create cancellation document
invoices.correct.title=Correct invoice {0}
invoices.correct.hint=A correction document is intended for formal errors only (e.g. address, delivery date). The original invoice remains visible and the correction document refers to it explicitly.
invoices.correct.fields=Corrected information
invoices.correct.fields.helper=Describe which fields are added or replaced.
invoices.correct.fields.required=Please describe the corrected information.
invoices.correct.reason=Reason for correction
invoices.correct.confirm=Create correction document
invoices.history.title=History for invoice {0}
invoices.history.log=Audit log
invoices.history.empty=No entries available.
invoices.history.original=Original invoice
invoices.history.cancellation=Cancellation document
invoices.history.correction=Correction document
invoices.history.replacement=Replacement invoice
invoices.audit.action.created_draft=Draft created
invoices.audit.action.updated_draft=Draft updated
invoices.audit.action.issued=Issued
invoices.audit.action.sent=Sent
invoices.audit.action.cancelled=Cancelled
invoices.audit.action.corrected=Corrected
invoices.audit.action.replaced=Replaced by new invoice
invoices.audit.action.deleted_draft=Draft deleted
invoices.audit.action.payment_recorded=Payment recorded
invoices.audit.resulting=Resulting document: {0}
invoices.payment.unpaid=Open
invoices.payment.partially_paid=Partially paid
invoices.payment.paid=Paid
invoices.payment.overpaid=Overpaid
invoices.payment.refund_due=Refund due
invoices.action.payment=Record payment
invoices.action.export=Export
invoices.payment.title=Payment for invoice {0}
invoices.payment.hint=Outstanding balance: {0}. Negative amounts can be recorded for corrections.
invoices.payment.amount=Amount
invoices.payment.amount.required=Please enter a non-zero amount.
invoices.payment.reference=Payment reference (e.g. statement, booking ID)
invoices.payment.reason=Note
invoices.payment.confirm=Record payment
invoices.notification.payment=Payment recorded.
# My Invoices # My Invoices
myinvoices.title=Invoices myinvoices.title=Invoices

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Remitente desconocido
# Add Job # Add Job
addjob.title=Crear nuevo pedido addjob.title=Crear nuevo pedido
addjob.customer.label=Cliente addjob.customer.label=Ordenante
addjob.customer.placeholder=Seleccionar cliente addjob.customer.placeholder=Seleccionar ordenante
addjob.customer.unnamed=Cliente sin nombre addjob.customer.unnamed=Ordenante sin nombre
addjob.button.clearfields=Vaciar campos addjob.button.clearfields=Vaciar campos
addjob.button.submit=Crear pedido addjob.button.submit=Crear pedido
addjob.address.salutation=Tratamiento addjob.address.salutation=Tratamiento
@@ -439,6 +439,10 @@ addjob.salutation.mr=Sr.
addjob.salutation.ms=Sra. addjob.salutation.ms=Sra.
addjob.salutation.other=Otro addjob.salutation.other=Otro
addjob.address.company.placeholder=Introducir empresa addjob.address.company.placeholder=Introducir empresa
addjob.address.pickup.label=Direcci\u00f3n de recogida
addjob.address.pickup.placeholder=Seleccionar o introducir direcci\u00f3n de recogida
addjob.address.delivery.label=Direcci\u00f3n de entrega
addjob.address.delivery.placeholder=Seleccionar o introducir direcci\u00f3n de entrega
addjob.address.street.placeholder=Introducir calle addjob.address.street.placeholder=Introducir calle
addjob.address.housenumber=N\u00famero de casa addjob.address.housenumber=N\u00famero de casa
addjob.address.addition.placeholder=Complemento de direcci\u00f3n addjob.address.addition.placeholder=Complemento de direcci\u00f3n
@@ -459,6 +463,8 @@ addjob.station.max.reached=Se ha alcanzado el n\u00famero m\u00e1ximo de 25 esta
addjob.station.unused=No utilizada addjob.station.unused=No utilizada
addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega. addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega.
addjob.tab.addresses=Cliente y direcciones addjob.tab.addresses=Cliente y direcciones
addjob.tab.pickup.address=Ordenante y direcci\u00f3n de recogida
addjob.tab.delivery.address=Direcci\u00f3n de entrega
addjob.tab.appointments=Citas y procesamiento addjob.tab.appointments=Citas y procesamiento
addjob.tab.cargo=Carga addjob.tab.cargo=Carga
addjob.tab.tasks=Tareas addjob.tab.tasks=Tareas
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Por favor, introduzca un motivo
jobsummary.dialog.manualcomplete.cancel=Cancelar jobsummary.dialog.manualcomplete.cancel=Cancelar
jobsummary.dialog.manualcomplete.confirm=Aceptar jobsummary.dialog.manualcomplete.confirm=Aceptar
jobsummary.history.manualcomplete.reason=Finalizado manualmente jobsummary.history.manualcomplete.reason=Finalizado manualmente
jobmanualcomplete.route.hours=Horas
jobmanualcomplete.route.minutes=Minutos
jobmanualcomplete.route.manual.hint=No hay datos de ruta disponibles introduzca la distancia y la duración manualmente.
# Jobs # Jobs
jobs.title=Pedidos jobs.title=Pedidos

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Exp\u00e9diteur inconnu
# Add Job # Add Job
addjob.title=Cr\u00e9er une nouvelle mission addjob.title=Cr\u00e9er une nouvelle mission
addjob.customer.label=Client addjob.customer.label=Donneur d'ordre
addjob.customer.placeholder=S\u00e9lectionner un client addjob.customer.placeholder=S\u00e9lectionner le donneur d'ordre
addjob.customer.unnamed=Client sans nom addjob.customer.unnamed=Donneur d'ordre sans nom
addjob.button.clearfields=Vider les champs addjob.button.clearfields=Vider les champs
addjob.button.submit=Cr\u00e9er la mission addjob.button.submit=Cr\u00e9er la mission
addjob.address.salutation=Civilit\u00e9 addjob.address.salutation=Civilit\u00e9
@@ -439,6 +439,10 @@ addjob.salutation.mr=Monsieur
addjob.salutation.ms=Madame addjob.salutation.ms=Madame
addjob.salutation.other=Autre addjob.salutation.other=Autre
addjob.address.company.placeholder=Saisir l'entreprise addjob.address.company.placeholder=Saisir l'entreprise
addjob.address.pickup.label=Adresse d'enl\u00e8vement
addjob.address.pickup.placeholder=S\u00e9lectionner ou saisir l'adresse d'enl\u00e8vement
addjob.address.delivery.label=Adresse de livraison
addjob.address.delivery.placeholder=S\u00e9lectionner ou saisir l'adresse de livraison
addjob.address.street.placeholder=Saisir la rue addjob.address.street.placeholder=Saisir la rue
addjob.address.housenumber=Num\u00e9ro addjob.address.housenumber=Num\u00e9ro
addjob.address.addition.placeholder=Compl\u00e9ment d'adresse addjob.address.addition.placeholder=Compl\u00e9ment d'adresse
@@ -459,6 +463,8 @@ addjob.station.max.reached=Nombre maximum de 25 stations de livraison atteint
addjob.station.unused=Non utilis\u00e9e addjob.station.unused=Non utilis\u00e9e
addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison. addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison.
addjob.tab.addresses=Donneur d'ordre & adresses addjob.tab.addresses=Donneur d'ordre & adresses
addjob.tab.pickup.address=Donneur d'ordre & adresse d'enl\u00e8vement
addjob.tab.delivery.address=Adresse de livraison
addjob.tab.appointments=Rendez-vous & traitement addjob.tab.appointments=Rendez-vous & traitement
addjob.tab.cargo=Fret addjob.tab.cargo=Fret
addjob.tab.tasks=T\u00e2ches addjob.tab.tasks=T\u00e2ches
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Veuillez saisir un motif
jobsummary.dialog.manualcomplete.cancel=Annuler jobsummary.dialog.manualcomplete.cancel=Annuler
jobsummary.dialog.manualcomplete.confirm=Accepter jobsummary.dialog.manualcomplete.confirm=Accepter
jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement
jobmanualcomplete.route.hours=Heures
jobmanualcomplete.route.minutes=Minutes
jobmanualcomplete.route.manual.hint=Aucune donn\u00e9e d'itin\u00e9raire disponible \u2013 veuillez saisir la distance et la dur\u00e9e manuellement.
# Jobs # Jobs
jobs.title=Missions jobs.title=Missions

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Nežinomas siuntėjas
# Add Job # Add Job
addjob.title=Sukurti naują užsakymą addjob.title=Sukurti naują užsakymą
addjob.customer.label=Klientas addjob.customer.label=Užsakovas
addjob.customer.placeholder=Pasirinkite klientą addjob.customer.placeholder=Pasirinkite užsakovą
addjob.customer.unnamed=Klientas be pavadinimo addjob.customer.unnamed=Neįvardytas užsakovas
addjob.button.clearfields=Išvalyti laukus addjob.button.clearfields=Išvalyti laukus
addjob.button.submit=Sukurti užsakymą addjob.button.submit=Sukurti užsakymą
addjob.address.salutation=Kreipinys addjob.address.salutation=Kreipinys
@@ -439,6 +439,10 @@ addjob.salutation.mr=Ponas
addjob.salutation.ms=Ponia addjob.salutation.ms=Ponia
addjob.salutation.other=Kita addjob.salutation.other=Kita
addjob.address.company.placeholder=Įveskite įmonę addjob.address.company.placeholder=Įveskite įmonę
addjob.address.pickup.label=Atsiėmimo adresas
addjob.address.pickup.placeholder=Pasirinkti arba įvesti atsiėmimo adresą
addjob.address.delivery.label=Pristatymo adresas
addjob.address.delivery.placeholder=Pasirinkti arba įvesti pristatymo adresą
addjob.address.street.placeholder=Įveskite gatvę addjob.address.street.placeholder=Įveskite gatvę
addjob.address.housenumber=Namo numeris addjob.address.housenumber=Namo numeris
addjob.address.addition.placeholder=Adreso priedas addjob.address.addition.placeholder=Adreso priedas
@@ -459,6 +463,8 @@ addjob.station.max.reached=Pasiektas maksimalus 25 pristatymo stočių skaičius
addjob.station.unused=Nenaudojama addjob.station.unused=Nenaudojama
addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse. addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse.
addjob.tab.addresses=Užsakovas ir adresai addjob.tab.addresses=Užsakovas ir adresai
addjob.tab.pickup.address=Užsakovas ir atsiėmimo adresas
addjob.tab.delivery.address=Pristatymo adresas
addjob.tab.appointments=Terminai ir apdorojimas addjob.tab.appointments=Terminai ir apdorojimas
addjob.tab.cargo=Krovinys addjob.tab.cargo=Krovinys
addjob.tab.tasks=Užduotys addjob.tab.tasks=Užduotys
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Prašome įvesti priežastį
jobsummary.dialog.manualcomplete.cancel=Atšaukti jobsummary.dialog.manualcomplete.cancel=Atšaukti
jobsummary.dialog.manualcomplete.confirm=Priimti jobsummary.dialog.manualcomplete.confirm=Priimti
jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu
jobmanualcomplete.route.hours=Valandos
jobmanualcomplete.route.minutes=Minutės
jobmanualcomplete.route.manual.hint=Maršruto duomenų nėra prašome įvesti atstumą ir trukmę rankiniu būdu.
# Jobs # Jobs
jobs.title=Užsakymai jobs.title=Užsakymai

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Nezināms sūtītājs
# Add Job # Add Job
addjob.title=Izveidot jaunu uzdevumu addjob.title=Izveidot jaunu uzdevumu
addjob.customer.label=Klients addjob.customer.label=Pasūtītājs
addjob.customer.placeholder=Izvēlēties klientu addjob.customer.placeholder=Izvēlēties pasūtītāju
addjob.customer.unnamed=Nenosaukts klients addjob.customer.unnamed=Nenosaukts pasūtītājs
addjob.button.clearfields=Notīrīt laukus addjob.button.clearfields=Notīrīt laukus
addjob.button.submit=Izveidot uzdevumu addjob.button.submit=Izveidot uzdevumu
addjob.address.salutation=Uzruna addjob.address.salutation=Uzruna
@@ -439,6 +439,10 @@ addjob.salutation.mr=Kungs
addjob.salutation.ms=Kundze addjob.salutation.ms=Kundze
addjob.salutation.other=Cits addjob.salutation.other=Cits
addjob.address.company.placeholder=Ievadiet uzņēmumu addjob.address.company.placeholder=Ievadiet uzņēmumu
addjob.address.pickup.label=Saņemšanas adrese
addjob.address.pickup.placeholder=Izvēlēties vai ievadīt saņemšanas adresi
addjob.address.delivery.label=Piegādes adrese
addjob.address.delivery.placeholder=Izvēlēties vai ievadīt piegādes adresi
addjob.address.street.placeholder=Ievadiet ielu addjob.address.street.placeholder=Ievadiet ielu
addjob.address.housenumber=Mājas numurs addjob.address.housenumber=Mājas numurs
addjob.address.addition.placeholder=Adreses papildinājums addjob.address.addition.placeholder=Adreses papildinājums
@@ -459,6 +463,8 @@ addjob.station.max.reached=Sasniegts maksimālais piegādes staciju skaits - 25
addjob.station.unused=Netiek izmantots addjob.station.unused=Netiek izmantots
addjob.appointment.delivery.info=Piegādes termiņi tiek noteikti tieši piegādes stacijās. addjob.appointment.delivery.info=Piegādes termiņi tiek noteikti tieši piegādes stacijās.
addjob.tab.addresses=Pasūtītājs un adreses addjob.tab.addresses=Pasūtītājs un adreses
addjob.tab.pickup.address=Pasūtītājs un saņemšanas adrese
addjob.tab.delivery.address=Piegādes adrese
addjob.tab.appointments=Termiņi un apstrāde addjob.tab.appointments=Termiņi un apstrāde
addjob.tab.cargo=Krava addjob.tab.cargo=Krava
addjob.tab.tasks=Uzdevuma darbības addjob.tab.tasks=Uzdevuma darbības
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Lūdzu, ievadiet pamatojumu
jobsummary.dialog.manualcomplete.cancel=Atcelt jobsummary.dialog.manualcomplete.cancel=Atcelt
jobsummary.dialog.manualcomplete.confirm=Apstiprināt jobsummary.dialog.manualcomplete.confirm=Apstiprināt
jobsummary.history.manualcomplete.reason=Pabeigts manuāli jobsummary.history.manualcomplete.reason=Pabeigts manuāli
jobmanualcomplete.route.hours=Stundas
jobmanualcomplete.route.minutes=Minūtes
jobmanualcomplete.route.manual.hint=Maršruta dati nav pieejami lūdzu, manuāli ievadiet attālumu un ilgumu.
# Jobs # Jobs
jobs.title=Uzdevumi jobs.title=Uzdevumi

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Nieznany nadawca
# Add Job # Add Job
addjob.title=Dodaj nowe zlecenie addjob.title=Dodaj nowe zlecenie
addjob.customer.label=Klient addjob.customer.label=Zleceniodawca
addjob.customer.placeholder=Wybierz klienta addjob.customer.placeholder=Wybierz zleceniodawcę
addjob.customer.unnamed=Klient bez nazwy addjob.customer.unnamed=Nienazwany zleceniodawca
addjob.button.clearfields=Wyczy\u015b\u0107 pola addjob.button.clearfields=Wyczy\u015b\u0107 pola
addjob.button.submit=Utw\u00f3rz zlecenie addjob.button.submit=Utw\u00f3rz zlecenie
addjob.address.salutation=Zwrot grzeczno\u015bciowy addjob.address.salutation=Zwrot grzeczno\u015bciowy
@@ -439,6 +439,10 @@ addjob.salutation.mr=Pan
addjob.salutation.ms=Pani addjob.salutation.ms=Pani
addjob.salutation.other=Inna addjob.salutation.other=Inna
addjob.address.company.placeholder=Wprowad\u017a firm\u0119 addjob.address.company.placeholder=Wprowad\u017a firm\u0119
addjob.address.pickup.label=Adres odbioru
addjob.address.pickup.placeholder=Wybierz lub wprowad\u017a adres odbioru
addjob.address.delivery.label=Adres dostawy
addjob.address.delivery.placeholder=Wybierz lub wprowad\u017a adres dostawy
addjob.address.street.placeholder=Wprowad\u017a ulic\u0119 addjob.address.street.placeholder=Wprowad\u017a ulic\u0119
addjob.address.housenumber=Numer domu addjob.address.housenumber=Numer domu
addjob.address.addition.placeholder=Dodatek do adresu addjob.address.addition.placeholder=Dodatek do adresu
@@ -459,6 +463,8 @@ addjob.station.max.reached=Osi\u0105gni\u0119to maksymaln\u0105 liczb\u0119 25 s
addjob.station.unused=Nieu\u017cywana addjob.station.unused=Nieu\u017cywana
addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy. addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy.
addjob.tab.addresses=Zleceniodawca i adresy addjob.tab.addresses=Zleceniodawca i adresy
addjob.tab.pickup.address=Zleceniodawca i adres odbioru
addjob.tab.delivery.address=Adres dostawy
addjob.tab.appointments=Terminy i przetwarzanie addjob.tab.appointments=Terminy i przetwarzanie
addjob.tab.cargo=\u0141adunek addjob.tab.cargo=\u0141adunek
addjob.tab.tasks=Zadania addjob.tab.tasks=Zadania
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Prosz\u0119 poda\u0107 uzasadni
jobsummary.dialog.manualcomplete.cancel=Anuluj jobsummary.dialog.manualcomplete.cancel=Anuluj
jobsummary.dialog.manualcomplete.confirm=Akceptuj jobsummary.dialog.manualcomplete.confirm=Akceptuj
jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie
jobmanualcomplete.route.hours=Godziny
jobmanualcomplete.route.minutes=Minuty
jobmanualcomplete.route.manual.hint=Brak danych trasy \u2013 prosz\u0119 r\u0119cznie poda\u0107 odleg\u0142o\u015b\u0107 i czas trwania.
# Jobs # Jobs
jobs.title=Zlecenia jobs.title=Zlecenia

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Неизвестный отправитель
# Add Job # Add Job
addjob.title=Создать новый заказ addjob.title=Создать новый заказ
addjob.customer.label=Клиент addjob.customer.label=Заказчик
addjob.customer.placeholder=Выберите клиента addjob.customer.placeholder=Выберите заказчика
addjob.customer.unnamed=Безымянный клиент addjob.customer.unnamed=Безымянный заказчик
addjob.button.clearfields=Очистить поля addjob.button.clearfields=Очистить поля
addjob.button.submit=Создать заказ addjob.button.submit=Создать заказ
addjob.address.salutation=Обращение addjob.address.salutation=Обращение
@@ -439,6 +439,10 @@ addjob.salutation.mr=Господин
addjob.salutation.ms=Госпожа addjob.salutation.ms=Госпожа
addjob.salutation.other=Другое addjob.salutation.other=Другое
addjob.address.company.placeholder=Введите компанию addjob.address.company.placeholder=Введите компанию
addjob.address.pickup.label=Адрес забора
addjob.address.pickup.placeholder=Выберите или введите адрес забора
addjob.address.delivery.label=Адрес доставки
addjob.address.delivery.placeholder=Выберите или введите адрес доставки
addjob.address.street.placeholder=Введите улицу addjob.address.street.placeholder=Введите улицу
addjob.address.housenumber=Номер дома addjob.address.housenumber=Номер дома
addjob.address.addition.placeholder=Дополнение к адресу addjob.address.addition.placeholder=Дополнение к адресу
@@ -459,6 +463,8 @@ addjob.station.max.reached=Достигнуто максимальное кол
addjob.station.unused=Не используется addjob.station.unused=Не используется
addjob.appointment.delivery.info=Сроки доставки устанавливаются непосредственно в станциях доставки. addjob.appointment.delivery.info=Сроки доставки устанавливаются непосредственно в станциях доставки.
addjob.tab.addresses=Заказчик и адреса addjob.tab.addresses=Заказчик и адреса
addjob.tab.pickup.address=Заказчик и адрес забора
addjob.tab.delivery.address=Адрес доставки
addjob.tab.appointments=Сроки и обработка addjob.tab.appointments=Сроки и обработка
addjob.tab.cargo=Груз addjob.tab.cargo=Груз
addjob.tab.tasks=Задачи addjob.tab.tasks=Задачи
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укаж
jobsummary.dialog.manualcomplete.cancel=Отмена jobsummary.dialog.manualcomplete.cancel=Отмена
jobsummary.dialog.manualcomplete.confirm=Принять jobsummary.dialog.manualcomplete.confirm=Принять
jobsummary.history.manualcomplete.reason=Завершено вручную jobsummary.history.manualcomplete.reason=Завершено вручную
jobmanualcomplete.route.hours=Часы
jobmanualcomplete.route.minutes=Минуты
jobmanualcomplete.route.manual.hint=Данные маршрута отсутствуют — пожалуйста, введите расстояние и продолжительность вручную.
# Jobs # Jobs
jobs.title=Заказы jobs.title=Заказы

View File

@@ -428,9 +428,9 @@ messages.sender.unknown=Bilinmeyen G\u00f6nderici
# Add Job # Add Job
addjob.title=Yeni \u0130\u015f Olu\u015ftur addjob.title=Yeni \u0130\u015f Olu\u015ftur
addjob.customer.label=M\u00fc\u015fteri addjob.customer.label=Sipari\u015f veren
addjob.customer.placeholder=M\u00fc\u015fteri Se\u00e7in addjob.customer.placeholder=Sipari\u015f vereni se\u00e7
addjob.customer.unnamed=\u0130simsiz M\u00fc\u015fteri addjob.customer.unnamed=\u0130simsiz sipari\u015f veren
addjob.button.clearfields=Alanlar\u0131 Temizle addjob.button.clearfields=Alanlar\u0131 Temizle
addjob.button.submit=\u0130\u015f Olu\u015ftur addjob.button.submit=\u0130\u015f Olu\u015ftur
addjob.address.salutation=Hitap addjob.address.salutation=Hitap
@@ -439,6 +439,10 @@ addjob.salutation.mr=Bay
addjob.salutation.ms=Bayan addjob.salutation.ms=Bayan
addjob.salutation.other=Di\u011fer addjob.salutation.other=Di\u011fer
addjob.address.company.placeholder=\u015eirketi girin addjob.address.company.placeholder=\u015eirketi girin
addjob.address.pickup.label=Al\u0131m adresi
addjob.address.pickup.placeholder=Al\u0131m adresi se\u00e7in veya girin
addjob.address.delivery.label=Teslimat adresi
addjob.address.delivery.placeholder=Teslimat adresi se\u00e7in veya girin
addjob.address.street.placeholder=Soka\u011f\u0131 girin addjob.address.street.placeholder=Soka\u011f\u0131 girin
addjob.address.housenumber=Kap\u0131 Numaras\u0131 addjob.address.housenumber=Kap\u0131 Numaras\u0131
addjob.address.addition.placeholder=Adres eki addjob.address.addition.placeholder=Adres eki
@@ -459,6 +463,8 @@ addjob.station.max.reached=Maksimum 25 teslimat istasyonu s\u0131n\u0131r\u0131n
addjob.station.unused=Kullan\u0131lm\u0131yor addjob.station.unused=Kullan\u0131lm\u0131yor
addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir. addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir.
addjob.tab.addresses=M\u00fc\u015fteri & Adresler addjob.tab.addresses=M\u00fc\u015fteri & Adresler
addjob.tab.pickup.address=Sipari\u015f veren ve al\u0131m adresi
addjob.tab.delivery.address=Teslimat adresi
addjob.tab.appointments=Randevular & \u0130\u015fleme addjob.tab.appointments=Randevular & \u0130\u015fleme
addjob.tab.cargo=Kargo addjob.tab.cargo=Kargo
addjob.tab.tasks=G\u00f6revler addjob.tab.tasks=G\u00f6revler
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Lütfen bir gerekçe girin
jobsummary.dialog.manualcomplete.cancel=İptal jobsummary.dialog.manualcomplete.cancel=İptal
jobsummary.dialog.manualcomplete.confirm=Kabul et jobsummary.dialog.manualcomplete.confirm=Kabul et
jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı
jobmanualcomplete.route.hours=Saat
jobmanualcomplete.route.minutes=Dakika
jobmanualcomplete.route.manual.hint=Rota verisi mevcut değil lütfen mesafeyi ve süreyi elle girin.
# Jobs # Jobs
jobs.title=\u0130\u015fler jobs.title=\u0130\u015fler

View File

@@ -0,0 +1,304 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Pflichtangaben-Validierung nach § 14 UStG. Jeder Test mutiert exakt eine
* Eigenschaft der Referenzrechnung — so bleibt sichtbar, welche Regel gerade
* geprüft wird, und die Tests fungieren gleichzeitig als ausführbare
* Spezifikation.
*/
class InvoiceComplianceValidatorTest {
private InvoiceComplianceValidator validator;
@BeforeEach
void setUp() {
validator = new InvoiceComplianceValidator();
}
@Test
void acceptsCompleteInvoice() {
CustomerInvoice invoice = validInvoice();
validator.validateForIssuance(invoice);
}
@Test
void rejectsMissingInvoiceNumber() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceNumber(" ");
assertSingleViolation(invoice, "Rechnungsnummer fehlt");
}
@Test
void rejectsMissingInvoiceDate() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceDate(null);
assertSingleViolation(invoice, "Rechnungsdatum");
}
@Test
void rejectsMissingDeliveryDate() {
CustomerInvoice invoice = validInvoice();
invoice.setDeliveryDate(null);
assertSingleViolation(invoice, "Leistungsdatum");
}
@Test
void rejectsMissingSenderName() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderName(null);
assertSingleViolation(invoice, "Name des Leistenden");
}
@Test
void rejectsIncompleteSenderAddress() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderPostcode("");
assertSingleViolation(invoice, "Anschrift des Leistenden");
}
@Test
void rejectsMissingSenderTaxIdentification() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderTaxNumber(null);
invoice.setSenderVatId(null);
assertSingleViolation(invoice, "Steuernummer oder USt-IdNr");
}
@Test
void acceptsSenderWithOnlyTaxNumber() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderVatId(null);
validator.validateForIssuance(invoice);
}
@Test
void acceptsSenderWithOnlyVatId() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderTaxNumber(null);
validator.validateForIssuance(invoice);
}
@Test
void rejectsMissingRecipientName() {
CustomerInvoice invoice = validInvoice();
invoice.setRecipientName(null);
assertSingleViolation(invoice, "Name des Leistungsempfängers");
}
@Test
void rejectsIncompleteRecipientAddress() {
CustomerInvoice invoice = validInvoice();
invoice.setRecipientCity(null);
assertSingleViolation(invoice, "Anschrift des Leistungsempfängers");
}
@Test
void rejectsEmptyItems() {
CustomerInvoice invoice = validInvoice();
invoice.setItems(new ArrayList<>());
assertSingleViolation(invoice, "Keine Positionen erfasst");
}
@Test
void rejectsItemWithoutDescription() {
CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setDescription("");
assertSingleViolation(invoice, "Bezeichnung der Leistung fehlt");
}
@Test
void rejectsItemWithZeroQuantity() {
CustomerInvoice invoice = validInvoice();
CustomerInvoiceItem item = invoice.getItems().get(0);
item.setQuantity(BigDecimal.ZERO);
// Zwingt netTotal = 0, damit nur die Mengenregel anschlägt und nicht zusätzlich
// die Konsistenzprüfung der Summen.
item.setNetTotal(BigDecimal.ZERO);
invoice.setNetAmount(BigDecimal.ZERO);
invoice.setVatAmount(BigDecimal.ZERO);
invoice.setTotalAmount(BigDecimal.ZERO);
// VAT-Hinweis muss bei 0 % aber gesetzt sein, sonst lösen wir zwei Verstöße aus.
invoice.setVatRate(new BigDecimal("0.19"));
assertSingleViolation(invoice, "Menge muss größer 0");
}
@Test
void rejectsItemWithNegativeUnitPrice() {
CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
}
@Test
void rejectsInconsistentTotals() {
CustomerInvoice invoice = validInvoice();
invoice.setTotalAmount(new BigDecimal("999.99"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
}
@Test
void rejectsItemSumMismatchingNet() {
CustomerInvoice invoice = validInvoice();
// Items summieren sich weiterhin auf 100.00, aber das deklarierte Netto wird verstellt.
invoice.setNetAmount(new BigDecimal("80.00"));
invoice.setVatAmount(new BigDecimal("15.20"));
invoice.setTotalAmount(new BigDecimal("95.20"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
}
@Test
void rejectsMissingVatRate() {
CustomerInvoice invoice = validInvoice();
invoice.setVatRate(null);
assertSingleViolation(invoice, "Steuersatz fehlt");
}
@Test
void rejectsZeroVatWithoutLegalNotice() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote(null);
invoice.setLegalNotes(null);
assertSingleViolation(invoice, "Bei 0 % USt ist ein rechtlicher Hinweis erforderlich");
}
@Test
void acceptsZeroVatWithReverseChargeNote() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG).");
invoice.setLegalNotes(null);
validator.validateForIssuance(invoice);
}
@Test
void acceptsZeroVatWithLegalNotes() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote(null);
invoice.setLegalNotes("Kleinunternehmer im Sinne des § 19 Abs. 1 UStG — keine Umsatzsteuer ausgewiesen.");
validator.validateForIssuance(invoice);
}
@Test
void rejectsMismatchingVatAmountForNonZeroRate() {
CustomerInvoice invoice = validInvoice();
// Erwartet wären 19,00 € — wir tragen 25,00 € ein.
invoice.setVatAmount(new BigDecimal("25.00"));
invoice.setTotalAmount(new BigDecimal("125.00"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
}
@Test
void collectsAllViolationsInOnePass() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceNumber(null);
invoice.setSenderName(null);
invoice.setItems(new ArrayList<>());
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations())
.as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen")
.hasSizeGreaterThanOrEqualTo(3);
}
@Test
void cancellationIsNotValidatedHere() {
CustomerInvoice invoice = validInvoice();
invoice.setType(InvoiceType.CANCELLATION);
invoice.setItems(new ArrayList<>()); // wäre für reguläre Rechnung ein Fehler
validator.validateForIssuance(invoice);
}
@Test
void correctionIsNotValidatedHere() {
CustomerInvoice invoice = validInvoice();
invoice.setType(InvoiceType.CORRECTION);
invoice.setSenderName(null); // wäre für reguläre Rechnung ein Fehler
validator.validateForIssuance(invoice);
}
@Test
void nullInvoiceIsRejectedDirectly() {
assertThatThrownBy(() -> validator.validateForIssuance(null))
.isInstanceOf(IllegalArgumentException.class);
}
private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull();
assertThat(ex.getViolations())
.as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations())
.anyMatch(v -> v.contains(fragment));
}
private CustomerInvoice validInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setType(InvoiceType.INVOICE);
invoice.setInvoiceNumber("R-2026-0001");
invoice.setInvoiceDate(LocalDate.of(2026, 5, 3));
invoice.setDeliveryDate(LocalDate.of(2026, 5, 3));
invoice.setSenderName("Votianlt Test GmbH");
invoice.setSenderAddress("Teststraße 1");
invoice.setSenderPostcode("12345");
invoice.setSenderCity("Berlin");
invoice.setSenderCountry("DE");
invoice.setSenderTaxNumber("12/345/67890");
invoice.setSenderVatId("DE123456789");
invoice.setRecipientName("Empfänger AG");
invoice.setRecipientAddress("Kundenweg 2");
invoice.setRecipientPostcode("54321");
invoice.setRecipientCity("Hamburg");
invoice.setRecipientCountry("DE");
CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung",
new BigDecimal("100.00"), new BigDecimal("0.19"));
invoice.setItems(new ArrayList<>(List.of(item)));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setVatAmount(new BigDecimal("19.00"));
invoice.setTotalAmount(new BigDecimal("119.00"));
return invoice;
}
private CustomerInvoice zeroVatInvoice() {
CustomerInvoice invoice = validInvoice();
invoice.setVatRate(BigDecimal.ZERO);
invoice.setVatAmount(BigDecimal.ZERO);
invoice.setTotalAmount(invoice.getNetAmount());
CustomerInvoiceItem item = invoice.getItems().get(0);
item.setVatRate(BigDecimal.ZERO);
item.setVatAmount(BigDecimal.ZERO);
item.setGrossTotal(item.getNetTotal());
return invoice;
}
}

View File

@@ -0,0 +1,171 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
@ExtendWith(MockitoExtension.class)
class InvoiceNumberAuditServiceTest {
@Mock
private InvoiceNumberReservationRepository repository;
private InvoiceNumberAuditService service;
private final ObjectId userId = new ObjectId();
@BeforeEach
void setUp() {
service = new InvoiceNumberAuditService(repository);
}
@Test
void markUsedTransitionsExistingReservation() {
InvoiceNumberReservation reservation = reservation("R-2026-000010", 10L,
InvoiceNumberReservationStatus.RESERVED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000010")).thenReturn(Optional.of(reservation));
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
CustomerInvoice invoice = invoice("R-2026-000010", "invoice-id-42");
service.markUsed(invoice);
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
assertThat(saved.getInvoiceId()).isEqualTo("invoice-id-42");
assertThat(saved.getUsedAt()).isNotNull();
}
@Test
void markUsedBootstrapsReservationForLegacyInvoiceWithoutPriorReservation() {
when(repository.findByUserIdAndNumber(userId, "RE-2024-0007")).thenReturn(Optional.empty());
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
service.markUsed(invoice("RE-2024-0007", "legacy-invoice"));
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
assertThat(saved.getNumber()).isEqualTo("RE-2024-0007");
assertThat(saved.getSequence()).isEqualTo(7L);
assertThat(saved.getReservedBy()).contains("bootstrap");
}
@Test
void markUsedSwallowsRepositoryFailures() {
when(repository.findByUserIdAndNumber(any(), any())).thenThrow(new RuntimeException("Mongo down"));
// Erwartung: keine Exception nach außen — Festschreiben darf an Audit nicht scheitern.
service.markUsed(invoice("R-2026-1", "i-1"));
}
@Test
void markUsedIgnoresInvoiceWithoutNumberOrUserId() {
CustomerInvoice missingNumber = new CustomerInvoice();
missingNumber.setUserId(userId.toHexString());
service.markUsed(missingNumber);
CustomerInvoice missingUser = new CustomerInvoice();
missingUser.setInvoiceNumber("R-1");
service.markUsed(missingUser);
verify(repository, never()).findByUserIdAndNumber(any(), any());
verify(repository, never()).save(any());
}
@Test
void markVoidedRequiresReason() {
assertThatThrownBy(() -> service.markVoided(userId, "R-1", " "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Grund");
assertThatThrownBy(() -> service.markVoided(userId, "R-1", null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void markVoidedTransitionsReservedToVoided() {
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
InvoiceNumberReservationStatus.RESERVED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
service.markVoided(userId, "R-2026-000005", "Versehentlich vergeben, Kunde widerrufen");
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.VOIDED);
assertThat(saved.getVoidReason()).isEqualTo("Versehentlich vergeben, Kunde widerrufen");
assertThat(saved.getVoidedAt()).isNotNull();
}
@Test
void markVoidedRefusesToOverwriteUsedReservation() {
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
InvoiceNumberReservationStatus.USED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
assertThatThrownBy(() -> service.markVoided(userId, "R-2026-000005", "Test"))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("ausgestellten Rechnung");
}
@Test
void markVoidedFailsWhenNoReservationFound() {
when(repository.findByUserIdAndNumber(userId, "R-NOPE")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.markVoided(userId, "R-NOPE", "irrelevant"))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("Keine Reservierung");
}
@Test
void findUnusedReturnsOnlyReservedAndVoided() {
when(repository.findByUserIdOrderBySequenceAsc(userId)).thenReturn(List.of(
reservation("R-1", 1L, InvoiceNumberReservationStatus.USED),
reservation("R-2", 2L, InvoiceNumberReservationStatus.RESERVED),
reservation("R-3", 3L, InvoiceNumberReservationStatus.USED),
reservation("R-4", 4L, InvoiceNumberReservationStatus.VOIDED)));
List<InvoiceNumberReservation> unused = service.findUnused(userId);
assertThat(unused).extracting(InvoiceNumberReservation::getNumber).containsExactly("R-2", "R-4");
}
private CustomerInvoice invoice(String number, String invoiceId) {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setId(invoiceId);
invoice.setInvoiceNumber(number);
invoice.setUserId(userId.toHexString());
return invoice;
}
private InvoiceNumberReservation reservation(String number, long sequence,
InvoiceNumberReservationStatus status) {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(number);
reservation.setSequence(sequence);
reservation.setStatus(status);
return reservation;
}
}