Compare commits
3 Commits
069b829294
...
31b18e1f52
| Author | SHA1 | Date | |
|---|---|---|---|
| 31b18e1f52 | |||
| d699609aa1 | |||
| 5ac629c23d |
@@ -11,7 +11,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.9.16</revision>
|
||||
<revision>0.9.17</revision>
|
||||
<java.version>21</java.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -44,6 +44,31 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</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>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Document(collection = "customerInvoices")
|
||||
@@ -12,6 +14,33 @@ public class CustomerInvoice {
|
||||
@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)
|
||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||
private LocalDate invoiceDate; // Rechnungsdatum
|
||||
@@ -372,4 +401,134 @@ public class CustomerInvoice {
|
||||
public void setPdfData(byte[] 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -254,9 +254,9 @@ public class DeliveryStationDialog extends Dialog {
|
||||
formLayout.setSpacing(true);
|
||||
formLayout.setWidthFull();
|
||||
|
||||
// Company with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
||||
// Delivery address with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
|
||||
company.setAllowCustomValue(true);
|
||||
company.setWidthFull();
|
||||
setupCompanyAutocomplete(company, customers);
|
||||
@@ -390,7 +390,7 @@ public class DeliveryStationDialog extends Dialog {
|
||||
addressTabError = 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);
|
||||
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
|
||||
createTasksTab(templates, templateSaveCallback));
|
||||
@@ -687,17 +687,8 @@ public class DeliveryStationDialog extends Dialog {
|
||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
||||
companyAddressOptions.clear();
|
||||
for (Customer customer : customers) {
|
||||
String label = buildCompanyAddressLabel(customer);
|
||||
if (label == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String uniqueLabel = label;
|
||||
int counter = 2;
|
||||
while (companyAddressOptions.containsKey(uniqueLabel)) {
|
||||
uniqueLabel = label + " (" + counter++ + ")";
|
||||
}
|
||||
companyAddressOptions.put(uniqueLabel, customer);
|
||||
CustomerAddressLabelHelper.putUnique(companyAddressOptions, customer,
|
||||
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
|
||||
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
||||
@@ -769,51 +760,8 @@ public class DeliveryStationDialog extends Dialog {
|
||||
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) {
|
||||
Customer customer = companyAddressOptions.get(comboValue);
|
||||
if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
||||
return customer.getCompanyName();
|
||||
}
|
||||
return comboValue;
|
||||
return CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, comboValue);
|
||||
}
|
||||
|
||||
private String findCompanyOptionLabel(DeliveryData data) {
|
||||
|
||||
@@ -16,8 +16,10 @@ import com.vaadin.flow.component.textfield.TextField;
|
||||
import de.assecutor.votianlt.model.Customer;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
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
|
||||
@@ -51,6 +53,7 @@ public class DeliveryStationTile extends VerticalLayout {
|
||||
private final TextField city;
|
||||
private final Checkbox saveAddress;
|
||||
private final H3 title;
|
||||
private final Map<String, Customer> companyAddressOptions = new LinkedHashMap<>();
|
||||
|
||||
private ChangeListener changeListener;
|
||||
private DeleteListener deleteListener;
|
||||
@@ -100,9 +103,9 @@ public class DeliveryStationTile extends VerticalLayout {
|
||||
|
||||
add(titleLayout);
|
||||
|
||||
// Company with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
||||
// Delivery address with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
|
||||
company.setAllowCustomValue(true);
|
||||
company.setWidthFull();
|
||||
setupCompanyAutocomplete(company, customers, translationHelper);
|
||||
@@ -224,22 +227,22 @@ public class DeliveryStationTile extends VerticalLayout {
|
||||
|
||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
|
||||
TranslationHelper translationHelper) {
|
||||
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
|
||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
||||
companyAddressOptions.clear();
|
||||
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 -> {
|
||||
String selectedCompany = event.getValue();
|
||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
||||
String selectedAddress = event.getValue();
|
||||
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Customer> matchingCustomer = customers.stream()
|
||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
||||
|
||||
if (matchingCustomer.isPresent()) {
|
||||
Customer customer = matchingCustomer.get();
|
||||
Customer customer = companyAddressOptions.get(selectedAddress);
|
||||
if (customer != null) {
|
||||
if (customer.getTitle() != null
|
||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||
@@ -282,7 +285,7 @@ public class DeliveryStationTile extends VerticalLayout {
|
||||
*/
|
||||
public DeliveryStation getDeliveryStation() {
|
||||
DeliveryStation station = new DeliveryStation();
|
||||
station.setCompany(company.getValue());
|
||||
station.setCompany(CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, company.getValue()));
|
||||
station.setSalutation(salutation.getValue());
|
||||
station.setFirstName(firstName.getValue());
|
||||
station.setLastName(lastName.getValue());
|
||||
|
||||
@@ -35,7 +35,6 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
@@ -303,7 +302,7 @@ public class PickupStationDialog extends Dialog {
|
||||
formLayout.setSpacing(true);
|
||||
formLayout.setWidthFull();
|
||||
|
||||
// Customer selection
|
||||
// Principal selection
|
||||
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
|
||||
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
|
||||
customerComboBox.setRequiredIndicatorVisible(true);
|
||||
@@ -311,27 +310,14 @@ public class PickupStationDialog extends Dialog {
|
||||
|
||||
customerLabelMap.clear();
|
||||
for (Customer c : customers) {
|
||||
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
|
||||
? 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 = translationHelper.getTranslation("addjob.customer.unnamed");
|
||||
}
|
||||
String uniqueLabel = label;
|
||||
int counter = 2;
|
||||
while (customerLabelMap.containsKey(uniqueLabel)) {
|
||||
uniqueLabel = label + " (" + counter++ + ")";
|
||||
}
|
||||
customerLabelMap.put(uniqueLabel, c);
|
||||
CustomerAddressLabelHelper.putUnique(customerLabelMap, c,
|
||||
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||
|
||||
// Company with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
||||
// Pickup address with autocomplete
|
||||
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.pickup.label"));
|
||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.pickup.placeholder"));
|
||||
company.setAllowCustomValue(true);
|
||||
company.setWidthFull();
|
||||
setupCompanyAutocomplete(company, customers);
|
||||
@@ -462,10 +448,7 @@ public class PickupStationDialog extends Dialog {
|
||||
return;
|
||||
}
|
||||
selectedCustomerId = c.getId();
|
||||
if (c.getCompanyName() != null)
|
||||
company.setValue(c.getCompanyName());
|
||||
else
|
||||
company.clear();
|
||||
setCompanySelection(c);
|
||||
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|
||||
|| "Divers".equalsIgnoreCase(c.getTitle())))
|
||||
salutation.setValue(c.getTitle());
|
||||
@@ -511,7 +494,12 @@ public class PickupStationDialog extends Dialog {
|
||||
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);
|
||||
|
||||
// TabSheet with address, appointments, and cargo tabs
|
||||
@@ -523,7 +511,7 @@ public class PickupStationDialog extends Dialog {
|
||||
appointmentsTabError = 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);
|
||||
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
|
||||
createAppointmentsTab(availableAppUsers));
|
||||
@@ -637,13 +625,23 @@ public class PickupStationDialog extends Dialog {
|
||||
public void setData(PickupData data) {
|
||||
if (data == null)
|
||||
return;
|
||||
if (data.getCustomerSelection() != null) {
|
||||
customerComboBox.setValue(data.getCustomerSelection());
|
||||
String customerSelection = normalizeValue(data.getCustomerSelection());
|
||||
if (!customerSelection.isEmpty()) {
|
||||
if (!customerLabelMap.containsKey(customerSelection)) {
|
||||
customerLabelMap.put(customerSelection, null);
|
||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||
}
|
||||
customerComboBox.setValue(customerSelection);
|
||||
} else {
|
||||
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());
|
||||
}
|
||||
if (data.getSalutation() != null)
|
||||
salutation.setValue(data.getSalutation());
|
||||
if (data.getFirstName() != null)
|
||||
@@ -689,19 +687,16 @@ public class PickupStationDialog extends Dialog {
|
||||
if (data.getCustomerId() != null) {
|
||||
selectedCustomerId = data.getCustomerId();
|
||||
} else {
|
||||
Customer matched = customerLabelMap.get(data.getCustomerSelection());
|
||||
if (matched == null) {
|
||||
matched = companyCustomerMap.get(normalizeValue(data.getCompany()));
|
||||
}
|
||||
Customer matched = companyOption != null ? companyCustomerMap.get(companyOption) : null;
|
||||
selectedCustomerId = matched != null ? matched.getId() : null;
|
||||
}
|
||||
saveAddress.setValue(data.isSaveAddress());
|
||||
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
|
||||
updateSaveAddressState();
|
||||
}
|
||||
|
||||
private PickupData collectData() {
|
||||
PickupData data = new PickupData();
|
||||
data.setCompany(company.getValue());
|
||||
data.setCompany(resolveCompanyValue(company.getValue()));
|
||||
data.setSalutation(salutation.getValue());
|
||||
data.setFirstName(firstName.getValue());
|
||||
data.setLastName(lastName.getValue());
|
||||
@@ -781,12 +776,9 @@ public class PickupStationDialog extends Dialog {
|
||||
private boolean validateMailField() {
|
||||
String value = mail.getValue();
|
||||
String normalizedValue = value == null ? "" : value.trim();
|
||||
boolean empty = normalizedValue.isEmpty();
|
||||
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
|
||||
boolean invalid = !empty && !normalizedValue.contains("@");
|
||||
boolean hasError = invalid || (required && empty);
|
||||
applyErrorStyling(mail, hasError);
|
||||
return !hasError;
|
||||
boolean invalid = !normalizedValue.isEmpty() && !normalizedValue.contains("@");
|
||||
applyErrorStyling(mail, invalid);
|
||||
return !invalid;
|
||||
}
|
||||
|
||||
private boolean validateCargoItems() {
|
||||
@@ -838,56 +830,24 @@ public class PickupStationDialog extends Dialog {
|
||||
}
|
||||
|
||||
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();
|
||||
for (Customer customer : customers) {
|
||||
String companyName = normalizeValue(customer.getCompanyName());
|
||||
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) {
|
||||
continue;
|
||||
CustomerAddressLabelHelper.putUnique(companyCustomerMap, customer,
|
||||
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
companyCustomerMap.put(companyName, customer);
|
||||
}
|
||||
companyField.setItems(companyNames);
|
||||
companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));
|
||||
|
||||
companyField.addValueChangeListener(event -> {
|
||||
String selectedCompany = event.getValue();
|
||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
||||
String selectedAddress = event.getValue();
|
||||
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||
selectedCustomerId = null;
|
||||
updateSaveAddressState();
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Customer> matchingCustomer = customers.stream()
|
||||
.filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst();
|
||||
|
||||
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());
|
||||
Customer customer = companyCustomerMap.get(selectedAddress);
|
||||
if (customer != null) {
|
||||
applyCustomerAddress(customer);
|
||||
}
|
||||
|
||||
updateSaveAddressState();
|
||||
@@ -902,7 +862,7 @@ public class PickupStationDialog extends Dialog {
|
||||
|
||||
private void updateSaveAddressState() {
|
||||
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 companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
|
||||
|
||||
@@ -924,11 +884,11 @@ public class PickupStationDialog extends Dialog {
|
||||
}
|
||||
|
||||
private void updateMailRequirement() {
|
||||
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
|
||||
mail.setRequiredIndicatorVisible(false);
|
||||
}
|
||||
|
||||
private boolean matchesCustomer(Customer customer) {
|
||||
return sameValue(company.getValue(), customer.getCompanyName())
|
||||
return sameValue(resolveCompanyValue(company.getValue()), customer.getCompanyName())
|
||||
&& sameValue(salutation.getValue(), customer.getTitle())
|
||||
&& sameValue(firstName.getValue(), customer.getFirstname())
|
||||
&& sameValue(lastName.getValue(), customer.getLastName())
|
||||
@@ -950,7 +910,7 @@ public class PickupStationDialog extends Dialog {
|
||||
}
|
||||
|
||||
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(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty()
|
||||
|| !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty()
|
||||
@@ -983,6 +943,108 @@ public class PickupStationDialog extends Dialog {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.bson.types.ObjectId;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Slice;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
import org.springframework.data.mongodb.repository.Query;
|
||||
import java.util.List;
|
||||
|
||||
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
|
||||
@@ -14,5 +15,8 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -43,4 +43,8 @@ public class CustomerService {
|
||||
return todoRepository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
public void deleteById(ObjectId id) {
|
||||
todoRepository.deleteById(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package de.assecutor.votianlt.pages.service;
|
||||
|
||||
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.security.SecurityService;
|
||||
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.MongoTemplate;
|
||||
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.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class UserInvoiceDataService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
|
||||
|
||||
private final UserInvoiceDataRepository userInvoiceDataRepository;
|
||||
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.mongoTemplate = mongoTemplate;
|
||||
this.reservationRepository = reservationRepository;
|
||||
this.securityService = securityService;
|
||||
}
|
||||
|
||||
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
|
||||
* 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) {
|
||||
Query query = Query.query(Criteria.where("userId").is(userId));
|
||||
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
|
||||
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
|
||||
return findByUserId(userId).map(d -> {
|
||||
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");
|
||||
}
|
||||
|
||||
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 : "";
|
||||
}
|
||||
}
|
||||
@@ -46,11 +46,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
public AddCustomerView(AddCustomerService todoService, Clock clock) {
|
||||
this.addCustomerService = todoService;
|
||||
|
||||
// Firma (Pflichtfeld)
|
||||
// Firma (optional; auch Privatpersonen können im Adressbuch stehen)
|
||||
companyName = new TextField(getTranslation("profile.company"));
|
||||
companyName.setRequiredIndicatorVisible(true);
|
||||
companyName.setWidthFull();
|
||||
companyName.addBlurListener(e -> validateField(companyName));
|
||||
|
||||
// Anrede (Dropdown)
|
||||
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
||||
@@ -162,8 +160,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
}
|
||||
|
||||
private void configureBinder() {
|
||||
binder.forField(companyName).asRequired(getTranslation("profile.validation.company.required"))
|
||||
.bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||
|
||||
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
|
||||
|
||||
@@ -257,7 +254,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
}
|
||||
|
||||
private boolean validateAllFields() {
|
||||
validateField(companyName);
|
||||
validateField(firstName);
|
||||
validateField(lastName);
|
||||
validateField(telephone);
|
||||
@@ -267,9 +263,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
validateField(city);
|
||||
validateEmail();
|
||||
|
||||
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
|
||||
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
|
||||
&& !city.isInvalid();
|
||||
return !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid()
|
||||
&& !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -63,6 +63,7 @@ import de.assecutor.votianlt.model.AddressValidationResult;
|
||||
import de.assecutor.votianlt.model.RouteCalculationResult;
|
||||
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.CustomerAddressLabelHelper;
|
||||
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.DialogStylingHelper;
|
||||
@@ -236,32 +237,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
|
||||
customerSelection.setWidthFull();
|
||||
customerSelection.setRequiredIndicatorVisible(true);
|
||||
customerSelection.setAllowCustomValue(true);
|
||||
customerSelection.addCustomValueSetListener(event -> setCustomerSelectionValue(event.getDetail()));
|
||||
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
|
||||
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
||||
customerLabelToEntity.clear();
|
||||
for (Customer c : ownerCustomers) {
|
||||
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
|
||||
? 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);
|
||||
CustomerAddressLabelHelper.putUnique(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||
|
||||
// Pickup address
|
||||
pickupCompany = new ComboBox<>(getTranslation("profile.company"));
|
||||
pickupCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder"));
|
||||
pickupCompany = new ComboBox<>(getTranslation("addjob.address.pickup.label"));
|
||||
pickupCompany.setPlaceholder(getTranslation("addjob.address.pickup.placeholder"));
|
||||
pickupCompany.setAllowCustomValue(true);
|
||||
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
|
||||
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
||||
@@ -857,7 +845,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
translationHelper, data -> {
|
||||
// Update customer selection from dialog
|
||||
if (data.getCustomerSelection() != null) {
|
||||
customerSelection.setValue(data.getCustomerSelection());
|
||||
setCustomerSelectionValue(data.getCustomerSelection());
|
||||
} else {
|
||||
customerSelection.clear();
|
||||
}
|
||||
@@ -1116,6 +1104,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
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) {
|
||||
// Ensure index is valid (could have changed due to deletions)
|
||||
int actualIndex = deliveryStationTilesList.indexOf(tile);
|
||||
@@ -1412,30 +1413,29 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
// Get all customers for the current owner
|
||||
List<Customer> allCustomers = customerService.findAllForCurrentOwner();
|
||||
|
||||
// Extract unique company names (filter out null/empty values)
|
||||
List<String> companyNames = allCustomers.stream().map(Customer::getCompanyName)
|
||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
||||
Map<String, Customer> addressOptions = new LinkedHashMap<>();
|
||||
for (Customer customer : allCustomers) {
|
||||
CustomerAddressLabelHelper.putUnique(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
|
||||
// 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
|
||||
// selected
|
||||
companyField.addValueChangeListener(event -> {
|
||||
String selectedCompany = event.getValue();
|
||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
||||
String selectedAddress = event.getValue();
|
||||
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Streckeninformationen zurücksetzen, da sich die Adresse ändert
|
||||
resetRouteInformation();
|
||||
|
||||
// Find the first customer with this company name
|
||||
Optional<Customer> matchingCustomer = allCustomers.stream()
|
||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
||||
Customer customer = addressOptions.get(selectedAddress);
|
||||
|
||||
if (matchingCustomer.isPresent()) {
|
||||
Customer customer = matchingCustomer.get();
|
||||
if (customer != null) {
|
||||
pickupCustomerId = customer.getId();
|
||||
|
||||
// Fill pickup address fields
|
||||
if (customer.getTitle() != null
|
||||
@@ -1476,6 +1476,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
|
||||
// Reactivate save checkbox for custom values
|
||||
savePickupAddress.setValue(true);
|
||||
pickupCustomerId = null;
|
||||
pickupMail = null;
|
||||
});
|
||||
}
|
||||
@@ -1987,7 +1988,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
*/
|
||||
private void loadJobIntoForm(Job job) {
|
||||
if (job.getCustomerSelection() != null) {
|
||||
customerSelection.setValue(job.getCustomerSelection());
|
||||
setCustomerSelectionValue(job.getCustomerSelection());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.service.CustomerService;
|
||||
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import de.assecutor.votianlt.security.SecurityService;
|
||||
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 jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -62,8 +63,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
private final InvoiceTemplateService invoiceTemplateService;
|
||||
private final SecurityService securityService;
|
||||
private final UserInvoiceDataService userInvoiceDataService;
|
||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||
private final CustomerService customerService;
|
||||
private final InvoiceLifecycleService invoiceLifecycleService;
|
||||
private User currentUser;
|
||||
private Job currentJob;
|
||||
private List<ServiceRow> gridRows = new ArrayList<>();
|
||||
@@ -117,8 +118,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
|
||||
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
||||
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
||||
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository,
|
||||
CustomerService customerService) {
|
||||
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
|
||||
InvoiceLifecycleService invoiceLifecycleService) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.serviceRepository = serviceRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -126,8 +127,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
this.invoiceTemplateService = invoiceTemplateService;
|
||||
this.securityService = securityService;
|
||||
this.userInvoiceDataService = userInvoiceDataService;
|
||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||
this.customerService = customerService;
|
||||
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||
setSizeFull();
|
||||
setPadding(true);
|
||||
setSpacing(true);
|
||||
@@ -584,8 +585,12 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
invoice.setVatRate(vatRate);
|
||||
invoice.setVatAmount(vatAmount);
|
||||
invoice.setTotalAmount(totalAmount);
|
||||
|
||||
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());
|
||||
jobRepository.save(currentJob);
|
||||
@@ -594,6 +599,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
|
||||
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) {
|
||||
log.error("Fehler beim Speichern der Rechnung", ex);
|
||||
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
|
||||
|
||||
@@ -191,6 +191,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
||||
|
||||
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
|
||||
if (customer != null && customer.getId() != null) {
|
||||
customerService.deleteById(customer.getId());
|
||||
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
|
||||
Notification.Position.MIDDLE);
|
||||
confirmDialog.close();
|
||||
|
||||
@@ -1,39 +1,82 @@
|
||||
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.html.Anchor;
|
||||
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.notification.Notification;
|
||||
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.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.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.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.service.UserInvoiceDataService;
|
||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
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 java.io.ByteArrayInputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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)
|
||||
@RolesAllowed({ "USER", "ADMIN" })
|
||||
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 CustomerInvoiceRepository customerInvoiceRepository;
|
||||
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.securityService = securityService;
|
||||
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||
this.customerInvoiceService = customerInvoiceService;
|
||||
this.invoiceExportService = invoiceExportService;
|
||||
this.invoicePermissionService = invoicePermissionService;
|
||||
this.userInvoiceDataService = userInvoiceDataService;
|
||||
this.userRepository = userRepository;
|
||||
|
||||
setSizeFull();
|
||||
setPadding(true);
|
||||
@@ -43,60 +86,502 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
||||
addClassName("data-view");
|
||||
|
||||
add(new ViewToolbar(getTranslation("invoices.title")));
|
||||
add(buildLegalDisclaimer());
|
||||
|
||||
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
|
||||
invoiceGrid.setWidthFull();
|
||||
invoiceGrid.addClassName("data-grid");
|
||||
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
|
||||
.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"))
|
||||
.setAutoWidth(true);
|
||||
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
|
||||
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
||||
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
||||
.setAutoWidth(true);
|
||||
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), ""))
|
||||
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true);
|
||||
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
|
||||
invoiceGrid.getStyle().set("cursor", "pointer");
|
||||
invoiceGrid.addComponentColumn(this::renderActions)
|
||||
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
|
||||
|
||||
invoiceGrid.addItemClickListener(event -> {
|
||||
CustomerInvoice invoice = event.getItem();
|
||||
if (invoice != null) {
|
||||
downloadInvoicePdf(invoice);
|
||||
}
|
||||
});
|
||||
invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
|
||||
|
||||
loadInvoices();
|
||||
Div gridPanel = new Div(invoiceGrid);
|
||||
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
||||
gridPanel.setWidthFull();
|
||||
|
||||
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() {
|
||||
String currentUserId = securityService.getCurrentUserId().toHexString();
|
||||
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
|
||||
.filter(this::hasPdfData).sorted((left, right) -> {
|
||||
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) {
|
||||
return 0;
|
||||
}
|
||||
if (left.getInvoiceDate() == null) {
|
||||
return 1;
|
||||
}
|
||||
if (right.getInvoiceDate() == null) {
|
||||
return -1;
|
||||
}
|
||||
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
|
||||
}).toList();
|
||||
.sorted(Comparator
|
||||
.comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
|
||||
: i.getInvoiceDate())
|
||||
.reversed())
|
||||
.toList();
|
||||
invoiceGrid.setItems(invoices);
|
||||
|
||||
if (invoices.isEmpty()) {
|
||||
Span emptyState = new Span(getTranslation("invoices.empty"));
|
||||
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
||||
add(emptyState);
|
||||
}
|
||||
|
||||
private Component renderStatusBadge(CustomerInvoice invoice) {
|
||||
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
|
||||
Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
|
||||
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) {
|
||||
@@ -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) {
|
||||
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
|
||||
}
|
||||
|
||||
@@ -2,22 +2,38 @@ package de.assecutor.votianlt.pages.view;
|
||||
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.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.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.NotificationVariant;
|
||||
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.textfield.IntegerField;
|
||||
import com.vaadin.flow.component.textfield.NumberField;
|
||||
import com.vaadin.flow.component.textfield.TextArea;
|
||||
import com.vaadin.flow.router.BeforeEvent;
|
||||
import com.vaadin.flow.router.HasDynamicTitle;
|
||||
import com.vaadin.flow.router.HasUrlParameter;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobHistoryType;
|
||||
import de.assecutor.votianlt.model.JobServiceSelection;
|
||||
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.repository.ServiceRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.security.SecurityService;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
@@ -25,23 +41,50 @@ import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
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)
|
||||
@RolesAllowed("USER")
|
||||
@Slf4j
|
||||
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 JobHistoryService jobHistoryService;
|
||||
private final SecurityService securityService;
|
||||
private final ServiceRepository serviceRepository;
|
||||
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,
|
||||
SecurityService securityService) {
|
||||
SecurityService securityService, ServiceRepository serviceRepository) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.jobHistoryService = jobHistoryService;
|
||||
this.securityService = securityService;
|
||||
this.serviceRepository = serviceRepository;
|
||||
|
||||
setSizeFull();
|
||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||
@@ -66,6 +109,8 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
||||
@Override
|
||||
public void setParameter(BeforeEvent event, String parameter) {
|
||||
content.removeAll();
|
||||
serviceRows.clear();
|
||||
job = null;
|
||||
|
||||
if (parameter == null || parameter.isBlank()) {
|
||||
content.add(new Span(getTranslation("jobhistory.error.no.id")));
|
||||
@@ -80,16 +125,32 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
||||
return;
|
||||
}
|
||||
|
||||
Job job = jobRepository.findById(jobId).orElse(null);
|
||||
if (job == null) {
|
||||
Job loaded = jobRepository.findById(jobId).orElse(null);
|
||||
if (loaded == null) {
|
||||
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
|
||||
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()));
|
||||
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);
|
||||
|
||||
boolean hasRouteData = manualDistanceKm != null && manualDistanceKm > 0;
|
||||
content.add(hasRouteData ? createRouteSection() : createManualRouteSection());
|
||||
|
||||
content.add(createServicesSection());
|
||||
content.add(createSummarySection());
|
||||
content.add(createRemarkSection());
|
||||
|
||||
HorizontalLayout buttonBar = new HorizontalLayout();
|
||||
buttonBar.setWidthFull();
|
||||
buttonBar.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END);
|
||||
buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
|
||||
buttonBar.setSpacing(true);
|
||||
|
||||
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
|
||||
@@ -110,7 +178,467 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
||||
|
||||
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
|
||||
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
|
||||
confirmButton.addClickListener(e -> {
|
||||
confirmButton.addClickListener(e -> confirm(reasonField));
|
||||
|
||||
buttonBar.add(cancelButton, confirmButton);
|
||||
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);
|
||||
@@ -120,6 +648,29 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
||||
|
||||
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);
|
||||
@@ -127,10 +678,9 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
||||
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"),
|
||||
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
|
||||
@@ -144,9 +694,5 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
||||
Notification.Position.BOTTOM_END)
|
||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
buttonBar.add(cancelButton, confirmButton);
|
||||
content.add(buttonBar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
||||
private final SecurityService securityService;
|
||||
private final ClientConnectionService clientConnectionService;
|
||||
private final MessagingPublisher messagingPublisher;
|
||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
||||
|
||||
@Autowired
|
||||
@@ -61,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
||||
this.securityService = securityService;
|
||||
this.clientConnectionService = clientConnectionService;
|
||||
this.messagingPublisher = messagingPublisher;
|
||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||
setSizeFull();
|
||||
setPadding(true);
|
||||
setSpacing(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.assecutor.votianlt.repository;
|
||||
|
||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
|
||||
Optional<CustomerInvoice> findByJobId(String jobId);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,4 +921,181 @@ public class CustomerInvoiceService {
|
||||
return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(" · 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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._-]", "_");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ page.title.appuser.create=Neuen App-Nutzer anlegen
|
||||
page.title.messages=Nachrichten
|
||||
page.title.register=Bei VotianLT registrieren
|
||||
page.title.customers=Kunden
|
||||
page.title.customer.edit=Kunde bearbeiten
|
||||
page.title.customer.edit=Adresse bearbeiten
|
||||
page.title.verwaltung=Verwaltung
|
||||
page.title.company.create=Neue Firma anlegen
|
||||
page.title.imprint=Impressum
|
||||
@@ -339,13 +339,13 @@ customers.column.street=Straße
|
||||
customers.column.city=Ort
|
||||
|
||||
# Edit Customer
|
||||
editcustomer.title=Kunde bearbeiten
|
||||
editcustomer.notification.notfound=Kunde nicht gefunden
|
||||
editcustomer.notification.invalid.id=Ungültige Kunden-ID
|
||||
editcustomer.notification.saved=Kunde erfolgreich gespeichert
|
||||
editcustomer.title=Adresse bearbeiten
|
||||
editcustomer.notification.notfound=Adresse nicht gefunden
|
||||
editcustomer.notification.invalid.id=Ungültige Adress-ID
|
||||
editcustomer.notification.saved=Adresse erfolgreich gespeichert
|
||||
editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
|
||||
editcustomer.notification.deleted=Kunde erfolgreich gelöscht
|
||||
editcustomer.dialog.delete.text=Möchten Sie diesen Kunden wirklich löschen?
|
||||
editcustomer.notification.deleted=Adresse erfolgreich gelöscht
|
||||
editcustomer.dialog.delete.text=Möchten Sie diese Adresse wirklich löschen?
|
||||
editcustomer.dialog.delete.confirm=Löschen
|
||||
|
||||
# Add Customer
|
||||
@@ -429,9 +429,9 @@ messages.sender.unknown=Unbekannter Absender
|
||||
|
||||
# Add Job
|
||||
addjob.title=Neuen Auftrag anlegen
|
||||
addjob.customer.label=Kunde
|
||||
addjob.customer.placeholder=Kunde auswählen
|
||||
addjob.customer.unnamed=Unbenannter Kunde
|
||||
addjob.customer.label=Auftraggeber
|
||||
addjob.customer.placeholder=Auftraggeber auswählen
|
||||
addjob.customer.unnamed=Unbenannter Auftraggeber
|
||||
addjob.button.clearfields=Felder leeren
|
||||
addjob.button.submit=Auftrag anlegen
|
||||
addjob.address.salutation=Anrede
|
||||
@@ -440,6 +440,10 @@ addjob.salutation.mr=Herr
|
||||
addjob.salutation.ms=Frau
|
||||
addjob.salutation.other=Divers
|
||||
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.housenumber=Hausnummer
|
||||
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.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt.
|
||||
addjob.tab.addresses=Auftraggeber & Adressen
|
||||
addjob.tab.pickup.address=Auftraggeber & Abholadresse
|
||||
addjob.tab.delivery.address=Lieferadresse
|
||||
addjob.tab.appointments=Termine & Verarbeitung
|
||||
addjob.tab.cargo=Fracht
|
||||
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.confirm=Akzeptiert
|
||||
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.title=Aufträge
|
||||
@@ -697,6 +706,72 @@ invoices.column.amount=Betrag
|
||||
invoices.column.description=Beschreibung
|
||||
invoices.empty=Es wurden noch keine Rechnungen erstellt.
|
||||
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=Originalrechnung
|
||||
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
|
||||
myinvoices.title=Rechnungen
|
||||
|
||||
@@ -377,9 +377,9 @@ messages.preview.image=Pilt
|
||||
messages.preview.empty=Eelvaade puudub
|
||||
messages.sender.unknown=Tundmatu saatja
|
||||
addjob.title=Uue tellimuse loomine
|
||||
addjob.customer.label=Klient
|
||||
addjob.customer.placeholder=Valige klient
|
||||
addjob.customer.unnamed=Nimetu klient
|
||||
addjob.customer.label=Tellija
|
||||
addjob.customer.placeholder=Vali tellija
|
||||
addjob.customer.unnamed=Nimetu tellija
|
||||
addjob.button.clearfields=T\u00fchjenda v\u00e4ljad
|
||||
addjob.button.submit=Loo tellimus
|
||||
addjob.address.salutation=P\u00f6\u00f6rdumine
|
||||
@@ -388,6 +388,10 @@ addjob.salutation.mr=Hr
|
||||
addjob.salutation.ms=Pr
|
||||
addjob.salutation.other=Muu
|
||||
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.housenumber=Majanumber
|
||||
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.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades.
|
||||
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.cargo=Veosed
|
||||
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.confirm=Nõustu
|
||||
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.filter.search=Otsi
|
||||
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...
|
||||
|
||||
@@ -429,9 +429,9 @@ messages.sender.unknown=Unknown Sender
|
||||
|
||||
# Add Job
|
||||
addjob.title=Create New Job
|
||||
addjob.customer.label=Customer
|
||||
addjob.customer.placeholder=Select Customer
|
||||
addjob.customer.unnamed=Unnamed Customer
|
||||
addjob.customer.label=Principal
|
||||
addjob.customer.placeholder=Select principal
|
||||
addjob.customer.unnamed=Unnamed principal
|
||||
addjob.button.clearfields=Clear Fields
|
||||
addjob.button.submit=Create Job
|
||||
addjob.address.salutation=Salutation
|
||||
@@ -440,6 +440,10 @@ addjob.salutation.mr=Mr
|
||||
addjob.salutation.ms=Ms
|
||||
addjob.salutation.other=Other
|
||||
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.housenumber=House Number
|
||||
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.appointment.delivery.info=Delivery dates are set directly in the delivery stations.
|
||||
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.cargo=Cargo
|
||||
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.confirm=Accept
|
||||
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.title=Jobs
|
||||
@@ -697,6 +706,72 @@ invoices.column.amount=Amount
|
||||
invoices.column.description=Description
|
||||
invoices.empty=No invoices have been created yet.
|
||||
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
|
||||
myinvoices.title=Invoices
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Remitente desconocido
|
||||
|
||||
# Add Job
|
||||
addjob.title=Crear nuevo pedido
|
||||
addjob.customer.label=Cliente
|
||||
addjob.customer.placeholder=Seleccionar cliente
|
||||
addjob.customer.unnamed=Cliente sin nombre
|
||||
addjob.customer.label=Ordenante
|
||||
addjob.customer.placeholder=Seleccionar ordenante
|
||||
addjob.customer.unnamed=Ordenante sin nombre
|
||||
addjob.button.clearfields=Vaciar campos
|
||||
addjob.button.submit=Crear pedido
|
||||
addjob.address.salutation=Tratamiento
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Sr.
|
||||
addjob.salutation.ms=Sra.
|
||||
addjob.salutation.other=Otro
|
||||
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.housenumber=N\u00famero de casa
|
||||
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.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega.
|
||||
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.cargo=Carga
|
||||
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.confirm=Aceptar
|
||||
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.title=Pedidos
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Exp\u00e9diteur inconnu
|
||||
|
||||
# Add Job
|
||||
addjob.title=Cr\u00e9er une nouvelle mission
|
||||
addjob.customer.label=Client
|
||||
addjob.customer.placeholder=S\u00e9lectionner un client
|
||||
addjob.customer.unnamed=Client sans nom
|
||||
addjob.customer.label=Donneur d'ordre
|
||||
addjob.customer.placeholder=S\u00e9lectionner le donneur d'ordre
|
||||
addjob.customer.unnamed=Donneur d'ordre sans nom
|
||||
addjob.button.clearfields=Vider les champs
|
||||
addjob.button.submit=Cr\u00e9er la mission
|
||||
addjob.address.salutation=Civilit\u00e9
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Monsieur
|
||||
addjob.salutation.ms=Madame
|
||||
addjob.salutation.other=Autre
|
||||
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.housenumber=Num\u00e9ro
|
||||
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.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.pickup.address=Donneur d'ordre & adresse d'enl\u00e8vement
|
||||
addjob.tab.delivery.address=Adresse de livraison
|
||||
addjob.tab.appointments=Rendez-vous & traitement
|
||||
addjob.tab.cargo=Fret
|
||||
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.confirm=Accepter
|
||||
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.title=Missions
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Nežinomas siuntėjas
|
||||
|
||||
# Add Job
|
||||
addjob.title=Sukurti naują užsakymą
|
||||
addjob.customer.label=Klientas
|
||||
addjob.customer.placeholder=Pasirinkite klientą
|
||||
addjob.customer.unnamed=Klientas be pavadinimo
|
||||
addjob.customer.label=Užsakovas
|
||||
addjob.customer.placeholder=Pasirinkite užsakovą
|
||||
addjob.customer.unnamed=Neįvardytas užsakovas
|
||||
addjob.button.clearfields=Išvalyti laukus
|
||||
addjob.button.submit=Sukurti užsakymą
|
||||
addjob.address.salutation=Kreipinys
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Ponas
|
||||
addjob.salutation.ms=Ponia
|
||||
addjob.salutation.other=Kita
|
||||
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.housenumber=Namo numeris
|
||||
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.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse.
|
||||
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.cargo=Krovinys
|
||||
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.confirm=Priimti
|
||||
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.title=Užsakymai
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Nezināms sūtītājs
|
||||
|
||||
# Add Job
|
||||
addjob.title=Izveidot jaunu uzdevumu
|
||||
addjob.customer.label=Klients
|
||||
addjob.customer.placeholder=Izvēlēties klientu
|
||||
addjob.customer.unnamed=Nenosaukts klients
|
||||
addjob.customer.label=Pasūtītājs
|
||||
addjob.customer.placeholder=Izvēlēties pasūtītāju
|
||||
addjob.customer.unnamed=Nenosaukts pasūtītājs
|
||||
addjob.button.clearfields=Notīrīt laukus
|
||||
addjob.button.submit=Izveidot uzdevumu
|
||||
addjob.address.salutation=Uzruna
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Kungs
|
||||
addjob.salutation.ms=Kundze
|
||||
addjob.salutation.other=Cits
|
||||
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.housenumber=Mājas numurs
|
||||
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.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.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.cargo=Krava
|
||||
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.confirm=Apstiprināt
|
||||
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.title=Uzdevumi
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Nieznany nadawca
|
||||
|
||||
# Add Job
|
||||
addjob.title=Dodaj nowe zlecenie
|
||||
addjob.customer.label=Klient
|
||||
addjob.customer.placeholder=Wybierz klienta
|
||||
addjob.customer.unnamed=Klient bez nazwy
|
||||
addjob.customer.label=Zleceniodawca
|
||||
addjob.customer.placeholder=Wybierz zleceniodawcę
|
||||
addjob.customer.unnamed=Nienazwany zleceniodawca
|
||||
addjob.button.clearfields=Wyczy\u015b\u0107 pola
|
||||
addjob.button.submit=Utw\u00f3rz zlecenie
|
||||
addjob.address.salutation=Zwrot grzeczno\u015bciowy
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Pan
|
||||
addjob.salutation.ms=Pani
|
||||
addjob.salutation.other=Inna
|
||||
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.housenumber=Numer domu
|
||||
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.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy.
|
||||
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.cargo=\u0141adunek
|
||||
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.confirm=Akceptuj
|
||||
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.title=Zlecenia
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Неизвестный отправитель
|
||||
|
||||
# Add Job
|
||||
addjob.title=Создать новый заказ
|
||||
addjob.customer.label=Клиент
|
||||
addjob.customer.placeholder=Выберите клиента
|
||||
addjob.customer.unnamed=Безымянный клиент
|
||||
addjob.customer.label=Заказчик
|
||||
addjob.customer.placeholder=Выберите заказчика
|
||||
addjob.customer.unnamed=Безымянный заказчик
|
||||
addjob.button.clearfields=Очистить поля
|
||||
addjob.button.submit=Создать заказ
|
||||
addjob.address.salutation=Обращение
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Господин
|
||||
addjob.salutation.ms=Госпожа
|
||||
addjob.salutation.other=Другое
|
||||
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.housenumber=Номер дома
|
||||
addjob.address.addition.placeholder=Дополнение к адресу
|
||||
@@ -459,6 +463,8 @@ addjob.station.max.reached=Достигнуто максимальное кол
|
||||
addjob.station.unused=Не используется
|
||||
addjob.appointment.delivery.info=Сроки доставки устанавливаются непосредственно в станциях доставки.
|
||||
addjob.tab.addresses=Заказчик и адреса
|
||||
addjob.tab.pickup.address=Заказчик и адрес забора
|
||||
addjob.tab.delivery.address=Адрес доставки
|
||||
addjob.tab.appointments=Сроки и обработка
|
||||
addjob.tab.cargo=Груз
|
||||
addjob.tab.tasks=Задачи
|
||||
@@ -620,6 +626,9 @@ jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укаж
|
||||
jobsummary.dialog.manualcomplete.cancel=Отмена
|
||||
jobsummary.dialog.manualcomplete.confirm=Принять
|
||||
jobsummary.history.manualcomplete.reason=Завершено вручную
|
||||
jobmanualcomplete.route.hours=Часы
|
||||
jobmanualcomplete.route.minutes=Минуты
|
||||
jobmanualcomplete.route.manual.hint=Данные маршрута отсутствуют — пожалуйста, введите расстояние и продолжительность вручную.
|
||||
|
||||
# Jobs
|
||||
jobs.title=Заказы
|
||||
|
||||
@@ -428,9 +428,9 @@ messages.sender.unknown=Bilinmeyen G\u00f6nderici
|
||||
|
||||
# Add Job
|
||||
addjob.title=Yeni \u0130\u015f Olu\u015ftur
|
||||
addjob.customer.label=M\u00fc\u015fteri
|
||||
addjob.customer.placeholder=M\u00fc\u015fteri Se\u00e7in
|
||||
addjob.customer.unnamed=\u0130simsiz M\u00fc\u015fteri
|
||||
addjob.customer.label=Sipari\u015f veren
|
||||
addjob.customer.placeholder=Sipari\u015f vereni se\u00e7
|
||||
addjob.customer.unnamed=\u0130simsiz sipari\u015f veren
|
||||
addjob.button.clearfields=Alanlar\u0131 Temizle
|
||||
addjob.button.submit=\u0130\u015f Olu\u015ftur
|
||||
addjob.address.salutation=Hitap
|
||||
@@ -439,6 +439,10 @@ addjob.salutation.mr=Bay
|
||||
addjob.salutation.ms=Bayan
|
||||
addjob.salutation.other=Di\u011fer
|
||||
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.housenumber=Kap\u0131 Numaras\u0131
|
||||
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.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir.
|
||||
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.cargo=Kargo
|
||||
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.confirm=Kabul et
|
||||
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.title=\u0130\u015fler
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user