From d73ad1457b9b82ffcceed7ff8f844c859610b7be Mon Sep 17 00:00:00 2001 From: Andreas Ivanovic <3028874@stud.th-mannheim.de> Date: Sat, 27 Jun 2026 22:04:26 +0200 Subject: [PATCH] feat: Dokumentenprozess Gruppe F - Implementierung und Tests --- .../faktura/dokumente/domain/Angebot.java | 49 +++ .../domain/Auftragsbestaetigung.java | 39 +++ .../faktura/dokumente/domain/Dokument.java | 101 ++++++ .../dokumente/domain/DokumentPosition.java | 74 ++++ .../dokumente/domain/DokumentStatus.java | 19 ++ .../dokumente/domain/DokumentType.java | 19 ++ .../dokumente/domain/Lieferschein.java | 47 +++ .../faktura/dokumente/domain/Rechnung.java | 91 +++++ .../domain/RechnungPflichtangaben.java | 70 ++++ .../exception/DokumentNotFoundException.java | 19 ++ .../exception/DuplicateDocumentException.java | 26 ++ .../exception/ValidationException.java | 27 ++ .../dokumente/export/PdfExportService.java | 22 ++ .../export/SimplePdfExportService.java | 125 +++++++ .../faktura/dokumente/lookup/CustomerDto.java | 31 ++ .../dokumente/lookup/CustomerLookup.java | 22 ++ .../faktura/dokumente/lookup/ProductDto.java | 34 ++ .../dokumente/lookup/ProductLookup.java | 21 ++ .../repository/DokumentRepository.java | 77 +++++ .../InMemoryDokumentRepository.java | 97 ++++++ .../service/DokumentNummernGenerator.java | 62 ++++ .../dokumente/service/DokumentService.java | 92 +++++ .../service/DokumentServiceImpl.java | 323 ++++++++++++++++++ .../dokumente/service/PositionCommand.java | 28 ++ .../RechnungPflichtangabenValidator.java | 77 +++++ 25 files changed, 1592 insertions(+) create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/Angebot.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/Auftragsbestaetigung.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/Dokument.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentPosition.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentStatus.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentType.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/Lieferschein.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/Rechnung.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/domain/RechnungPflichtangaben.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/exception/DokumentNotFoundException.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/exception/DuplicateDocumentException.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/exception/ValidationException.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/export/PdfExportService.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/export/SimplePdfExportService.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerDto.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerLookup.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductDto.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductLookup.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/repository/DokumentRepository.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/repository/InMemoryDokumentRepository.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentNummernGenerator.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentService.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentServiceImpl.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/service/PositionCommand.java create mode 100644 src/main/java/de/hsmannheim/faktura/dokumente/service/RechnungPflichtangabenValidator.java diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/Angebot.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Angebot.java new file mode 100644 index 0000000..493d738 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Angebot.java @@ -0,0 +1,49 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.time.LocalDate; +import java.util.List; + +/** + * Preisangebot an einen Kunden – Ausgangspunkt der Dokumentenkette. + * Format der Nummer: A-yyyy-nnnn + * + * Statusübergänge: OFFEN → UEBERFUEHRT (wenn AB erzeugt wird) + * Bearbeitung nur möglich solange Status == OFFEN (PH-DP-03). + */ +public class Angebot extends Dokument { + + public Angebot() { + super(); + setTyp(DokumentType.ANGEBOT); + setStatus(DokumentStatus.OFFEN); + setDatum(LocalDate.now()); + } + + public Angebot(String dokumentNummer, long kundenId, LocalDate datum, + List positionen) { + super(dokumentNummer, kundenId, datum, + DokumentType.ANGEBOT, DokumentStatus.OFFEN, positionen); + } + + /** + * Prüft ob das Angebot noch bearbeitbar ist. + * @throws IllegalStateException wenn Status != OFFEN + */ + public void pruefeBearbeitbar() { + if (getStatus() != DokumentStatus.OFFEN) { + throw new IllegalStateException( + "Angebot " + getDokumentNummer() + " kann nicht mehr bearbeitet werden, " + + "da es bereits überführt wurde (Status: " + getStatus() + ")." + ); + } + } + + /** + * Setzt das Angebot auf UEBERFUEHRT (wird aufgerufen wenn AB erzeugt wird). + * @throws IllegalStateException wenn Status != OFFEN + */ + public void ueberFuehren() { + pruefeBearbeitbar(); + setStatus(DokumentStatus.UEBERFUEHRT); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/Auftragsbestaetigung.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Auftragsbestaetigung.java new file mode 100644 index 0000000..1a18f11 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Auftragsbestaetigung.java @@ -0,0 +1,39 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.time.LocalDate; +import java.util.List; + +/** + * Verbindliche Bestätigung der Annahme eines Angebots. + * Format der Nummer: AB-yyyy-nnnn + * + * Referenziert das Quellangebot über die Angebotsnummer (PH-DP-04). + */ +public class Auftragsbestaetigung extends Dokument { + + /** Nummer des Angebots, aus dem diese AB erzeugt wurde. */ + private String angebotsNummer; + + public Auftragsbestaetigung() { + super(); + setTyp(DokumentType.AUFTRAGSBESTAETIGUNG); + setStatus(DokumentStatus.BESTAETIGT); + setDatum(LocalDate.now()); + } + + public Auftragsbestaetigung(String dokumentNummer, long kundenId, LocalDate datum, + List positionen, String angebotsNummer) { + super(dokumentNummer, kundenId, datum, + DokumentType.AUFTRAGSBESTAETIGUNG, DokumentStatus.BESTAETIGT, positionen); + this.angebotsNummer = angebotsNummer; + } + + public String getAngebotsNummer() { return angebotsNummer; } + public void setAngebotsNummer(String angebotsNummer) { this.angebotsNummer = angebotsNummer; } + + @Override + public String toString() { + return String.format("Auftragsbestaetigung{nummer='%s', angebotsNummer='%s', kundenId=%d}", + getDokumentNummer(), angebotsNummer, getKundenId()); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/Dokument.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Dokument.java new file mode 100644 index 0000000..5cf6139 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Dokument.java @@ -0,0 +1,101 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Abstrakte Basisklasse für alle Dokumente im Fakturierungssystem. + * Spezialisierungen: {@link Angebot}, {@link Auftragsbestaetigung}, + * {@link Lieferschein}, {@link Rechnung}. + */ +public abstract class Dokument { + + private String dokumentNummer; + private long kundenId; + private LocalDate datum; + private DokumentType typ; + private DokumentStatus status; + private List positionen; + + /** Berechnete Summen. */ + private BigDecimal nettoSumme = BigDecimal.ZERO; + private BigDecimal ustSumme = BigDecimal.ZERO; + private BigDecimal bruttoSumme = BigDecimal.ZERO; + + protected Dokument() { + this.positionen = new ArrayList<>(); + } + + protected Dokument(String dokumentNummer, long kundenId, LocalDate datum, + DokumentType typ, DokumentStatus status, + List positionen) { + this.dokumentNummer = dokumentNummer; + this.kundenId = kundenId; + this.datum = datum; + this.typ = typ; + this.status = status; + this.positionen = new ArrayList<>(positionen); + } + + /** + * Berechnet Netto-, USt.- und Bruttosumme aus den Positionen. + * Muss nach jeder Positionsänderung aufgerufen werden. + */ + public void berechneSummen() { + nettoSumme = positionen.stream() + .map(DokumentPosition::getNettoGesamt) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(2, RoundingMode.HALF_UP); + + ustSumme = positionen.stream() + .map(DokumentPosition::getUstBetrag) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(2, RoundingMode.HALF_UP); + + bruttoSumme = nettoSumme.add(ustSumme).setScale(2, RoundingMode.HALF_UP); + } + + // --- Getter & Setter --- + + public String getDokumentNummer() { return dokumentNummer; } + public void setDokumentNummer(String dokumentNummer) { this.dokumentNummer = dokumentNummer; } + + public long getKundenId() { return kundenId; } + public void setKundenId(long kundenId) { this.kundenId = kundenId; } + + public LocalDate getDatum() { return datum; } + public void setDatum(LocalDate datum) { this.datum = datum; } + + public DokumentType getTyp() { return typ; } + public void setTyp(DokumentType typ) { this.typ = typ; } + + public DokumentStatus getStatus() { return status; } + public void setStatus(DokumentStatus status) { this.status = status; } + + public List getPositionen() { + return Collections.unmodifiableList(positionen); + } + + public void setPositionen(List positionen) { + this.positionen = new ArrayList<>(positionen); + } + + public BigDecimal getNettoSumme() { return nettoSumme; } + public BigDecimal getUstSumme() { return ustSumme; } + public BigDecimal getBruttoSumme() { return bruttoSumme; } + + /** Nur für Deserialisierung (SQLite). */ + public void setNettoSumme(BigDecimal nettoSumme) { this.nettoSumme = nettoSumme; } + public void setUstSumme(BigDecimal ustSumme) { this.ustSumme = ustSumme; } + public void setBruttoSumme(BigDecimal bruttoSumme) { this.bruttoSumme = bruttoSumme; } + + @Override + public String toString() { + return String.format("%s{nummer='%s', kundenId=%d, datum=%s, status=%s}", + getClass().getSimpleName(), dokumentNummer, kundenId, datum, status); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentPosition.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentPosition.java new file mode 100644 index 0000000..716efad --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentPosition.java @@ -0,0 +1,74 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Eine Position innerhalb eines Dokuments. + * Produktbezeichnung, Einzelpreis und MwSt-Satz werden als Snapshot + * gespeichert, damit spätere Produktänderungen bestehende Dokumente + * nicht beeinflussen (PH-DP-14). + */ +public class DokumentPosition { + + private final long produktId; + private final String bezeichnungSnapshot; + private final int menge; + private final BigDecimal einzelpreisNettoSnapshot; + private final BigDecimal mwstSatzSnapshot; + + public DokumentPosition(long produktId, + String bezeichnungSnapshot, + int menge, + BigDecimal einzelpreisNettoSnapshot, + BigDecimal mwstSatzSnapshot) { + if (menge <= 0) { + throw new IllegalArgumentException("Menge muss > 0 sein, war: " + menge); + } + if (einzelpreisNettoSnapshot == null || einzelpreisNettoSnapshot.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Einzelpreis darf nicht negativ sein"); + } + if (bezeichnungSnapshot == null || bezeichnungSnapshot.isBlank()) { + throw new IllegalArgumentException("Bezeichnung darf nicht leer sein"); + } + this.produktId = produktId; + this.bezeichnungSnapshot = bezeichnungSnapshot; + this.menge = menge; + this.einzelpreisNettoSnapshot = einzelpreisNettoSnapshot.setScale(2, RoundingMode.HALF_UP); + this.mwstSatzSnapshot = mwstSatzSnapshot != null ? mwstSatzSnapshot : BigDecimal.ZERO; + } + + /** Nettogesamtbetrag dieser Position (Preis * Menge). */ + public BigDecimal getNettoGesamt() { + return einzelpreisNettoSnapshot + .multiply(BigDecimal.valueOf(menge)) + .setScale(2, RoundingMode.HALF_UP); + } + + /** USt-Betrag dieser Position. */ + public BigDecimal getUstBetrag() { + return getNettoGesamt() + .multiply(mwstSatzSnapshot) + .setScale(2, RoundingMode.HALF_UP); + } + + /** Bruttogesamtbetrag dieser Position. */ + public BigDecimal getBruttoGesamt() { + return getNettoGesamt().add(getUstBetrag()).setScale(2, RoundingMode.HALF_UP); + } + + // --- Getter --- + + public long getProduktId() { return produktId; } + public String getBezeichnungSnapshot() { return bezeichnungSnapshot; } + public int getMenge() { return menge; } + public BigDecimal getEinzelpreisNettoSnapshot() { return einzelpreisNettoSnapshot; } + public BigDecimal getMwstSatzSnapshot() { return mwstSatzSnapshot; } + + @Override + public String toString() { + return String.format("DokumentPosition{produktId=%d, bezeichnung='%s', menge=%d, " + + "einzelpreisNetto=%s, mwstSatz=%s}", + produktId, bezeichnungSnapshot, menge, einzelpreisNettoSnapshot, mwstSatzSnapshot); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentStatus.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentStatus.java new file mode 100644 index 0000000..3a58b09 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentStatus.java @@ -0,0 +1,19 @@ +package de.hsmannheim.faktura.dokumente.domain; + +/** + * Steuert erlaubte Folgeaktionen im Dokumentenprozess. + * + * Workflow: + * ANGEBOT: OFFEN → UEBERFUEHRT + * AUFTRAGSBESTAETIGUNG: BESTAETIGT + * LIEFERSCHEIN: GELIEFERT + * RECHNUNG: FESTGESCHRIEBEN + */ +public enum DokumentStatus { + OFFEN, + UEBERFUEHRT, + BESTAETIGT, + GELIEFERT, + FAKTURIERT, + FESTGESCHRIEBEN +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentType.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentType.java new file mode 100644 index 0000000..351a018 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/DokumentType.java @@ -0,0 +1,19 @@ +package de.hsmannheim.faktura.dokumente.domain; + +/** Typisierung für Anzeige, Export und Repository. */ +public enum DokumentType { + ANGEBOT("A"), + AUFTRAGSBESTAETIGUNG("AB"), + LIEFERSCHEIN("LS"), + RECHNUNG("R"); + + private final String praefix; + + DokumentType(String praefix) { + this.praefix = praefix; + } + + public String getPraefix() { + return praefix; + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/Lieferschein.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Lieferschein.java new file mode 100644 index 0000000..d4b2fe8 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Lieferschein.java @@ -0,0 +1,47 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.time.LocalDate; +import java.util.List; + +/** + * Dokumentiert die Lieferung – enthält keine Preise (PH-DP-07). + * Format der Nummer: LS-yyyy-nnnn + * + * Referenziert die Auftragsbestätigung über die AB-Nummer (PH-DP-06). + */ +public class Lieferschein extends Dokument { + + /** Nummer der AB, aus der dieser Lieferschein erzeugt wurde. */ + private String auftragsNummer; + + /** Datum der tatsächlichen Lieferung. */ + private LocalDate lieferdatum; + + public Lieferschein() { + super(); + setTyp(DokumentType.LIEFERSCHEIN); + setStatus(DokumentStatus.GELIEFERT); + setDatum(LocalDate.now()); + } + + public Lieferschein(String dokumentNummer, long kundenId, LocalDate datum, + List positionen, + String auftragsNummer, LocalDate lieferdatum) { + super(dokumentNummer, kundenId, datum, + DokumentType.LIEFERSCHEIN, DokumentStatus.GELIEFERT, positionen); + this.auftragsNummer = auftragsNummer; + this.lieferdatum = lieferdatum; + } + + public String getAuftragsNummer() { return auftragsNummer; } + public void setAuftragsNummer(String auftragsNummer) { this.auftragsNummer = auftragsNummer; } + + public LocalDate getLieferdatum() { return lieferdatum; } + public void setLieferdatum(LocalDate lieferdatum) { this.lieferdatum = lieferdatum; } + + @Override + public String toString() { + return String.format("Lieferschein{nummer='%s', auftragsNummer='%s', lieferdatum=%s}", + getDokumentNummer(), auftragsNummer, lieferdatum); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/Rechnung.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Rechnung.java new file mode 100644 index 0000000..aa62ad4 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/Rechnung.java @@ -0,0 +1,91 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.time.LocalDate; +import java.util.List; + +/** + * Festgeschriebenes Dokument mit Zahlungsforderung und Pflichtangaben nach UStG. + * Format der Nummer: R-yyyy-nnnn + * + * Eine Rechnung ist nach dem Speichern inhaltlich gesperrt (PH-DP-11). + */ +public class Rechnung extends Dokument { + + /** Nummer des Lieferscheins, aus dem diese Rechnung erzeugt wurde. */ + private String lieferscheinNummer; + + /** Rechnungsdatum (i. d. R. Erstellungsdatum). */ + private LocalDate rechnungsdatum; + + /** Pflichtangaben nach UStG §14. */ + private RechnungPflichtangaben pflichtangaben; + + /** + * true = Rechnung ist festgeschrieben, Änderungen werden abgelehnt (PH-DP-11). + */ + private boolean festgeschrieben = false; + + public Rechnung() { + super(); + setTyp(DokumentType.RECHNUNG); + setStatus(DokumentStatus.FESTGESCHRIEBEN); + setDatum(LocalDate.now()); + this.rechnungsdatum = LocalDate.now(); + } + + public Rechnung(String dokumentNummer, long kundenId, LocalDate datum, + List positionen, + String lieferscheinNummer, LocalDate rechnungsdatum, + RechnungPflichtangaben pflichtangaben) { + super(dokumentNummer, kundenId, datum, + DokumentType.RECHNUNG, DokumentStatus.FESTGESCHRIEBEN, positionen); + this.lieferscheinNummer = lieferscheinNummer; + this.rechnungsdatum = rechnungsdatum; + this.pflichtangaben = pflichtangaben; + this.festgeschrieben = true; + } + + /** + * Prüft ob die Rechnung noch änderbar ist. + * @throws IllegalStateException wenn festgeschrieben == true + */ + public void pruefeAenderbar() { + if (festgeschrieben) { + throw new IllegalStateException( + "Rechnung " + getDokumentNummer() + " ist festgeschrieben und kann nicht " + + "mehr geändert werden (PH-DP-11)." + ); + } + } + + /** Schreibt die Rechnung fest – danach sind keine Änderungen mehr möglich. */ + public void festschreiben() { + this.festgeschrieben = true; + setStatus(DokumentStatus.FESTGESCHRIEBEN); + } + + // --- Getter & Setter --- + + public String getLieferscheinNummer() { return lieferscheinNummer; } + public void setLieferscheinNummer(String lieferscheinNummer) { + this.lieferscheinNummer = lieferscheinNummer; + } + + public LocalDate getRechnungsdatum() { return rechnungsdatum; } + public void setRechnungsdatum(LocalDate rechnungsdatum) { this.rechnungsdatum = rechnungsdatum; } + + public RechnungPflichtangaben getPflichtangaben() { return pflichtangaben; } + public void setPflichtangaben(RechnungPflichtangaben pflichtangaben) { + this.pflichtangaben = pflichtangaben; + } + + public boolean isFestgeschrieben() { return festgeschrieben; } + public void setFestgeschrieben(boolean festgeschrieben) { this.festgeschrieben = festgeschrieben; } + + @Override + public String toString() { + return String.format("Rechnung{nummer='%s', lieferscheinNummer='%s', rechnungsdatum=%s, " + + "festgeschrieben=%b, brutto=%s}", + getDokumentNummer(), lieferscheinNummer, rechnungsdatum, festgeschrieben, getBruttoSumme()); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/domain/RechnungPflichtangaben.java b/src/main/java/de/hsmannheim/faktura/dokumente/domain/RechnungPflichtangaben.java new file mode 100644 index 0000000..0c987e0 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/domain/RechnungPflichtangaben.java @@ -0,0 +1,70 @@ +package de.hsmannheim.faktura.dokumente.domain; + +import java.time.LocalDate; + +/** + * Pflichtangaben einer Rechnung nach UStG §14. + * Enthält Informationen über Leistungserbringer, Empfänger und Leistungszeitraum. + */ +public class RechnungPflichtangaben { + + /** Name des Rechnungsausstellers (Verkäufer/Leistungserbringer). */ + private String leistungserbringerName; + + /** Anschrift des Rechnungsausstellers. */ + private String leistungserbringerAnschrift; + + /** Steuernummer oder UStIdNr des Rechnungsausstellers. */ + private String steuerNr; + + /** Name des Rechnungsempfängers (Käufer/Leistungsempfänger). */ + private String empfaengerName; + + /** Anschrift des Rechnungsempfängers. */ + private String empfaengerAnschrift; + + /** Datum, an dem die Leistung erbracht / geliefert wurde. */ + private LocalDate leistungsdatum; + + public RechnungPflichtangaben() {} + + public RechnungPflichtangaben(String leistungserbringerName, + String leistungserbringerAnschrift, + String steuerNr, + String empfaengerName, + String empfaengerAnschrift, + LocalDate leistungsdatum) { + this.leistungserbringerName = leistungserbringerName; + this.leistungserbringerAnschrift = leistungserbringerAnschrift; + this.steuerNr = steuerNr; + this.empfaengerName = empfaengerName; + this.empfaengerAnschrift = empfaengerAnschrift; + this.leistungsdatum = leistungsdatum; + } + + // --- Getter & Setter --- + + public String getLeistungserbringerName() { return leistungserbringerName; } + public void setLeistungserbringerName(String leistungserbringerName) { + this.leistungserbringerName = leistungserbringerName; + } + + public String getLeistungserbringerAnschrift() { return leistungserbringerAnschrift; } + public void setLeistungserbringerAnschrift(String leistungserbringerAnschrift) { + this.leistungserbringerAnschrift = leistungserbringerAnschrift; + } + + public String getSteuerNr() { return steuerNr; } + public void setSteuerNr(String steuerNr) { this.steuerNr = steuerNr; } + + public String getEmpfaengerName() { return empfaengerName; } + public void setEmpfaengerName(String empfaengerName) { this.empfaengerName = empfaengerName; } + + public String getEmpfaengerAnschrift() { return empfaengerAnschrift; } + public void setEmpfaengerAnschrift(String empfaengerAnschrift) { + this.empfaengerAnschrift = empfaengerAnschrift; + } + + public LocalDate getLeistungsdatum() { return leistungsdatum; } + public void setLeistungsdatum(LocalDate leistungsdatum) { this.leistungsdatum = leistungsdatum; } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/exception/DokumentNotFoundException.java b/src/main/java/de/hsmannheim/faktura/dokumente/exception/DokumentNotFoundException.java new file mode 100644 index 0000000..ef89a9e --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/exception/DokumentNotFoundException.java @@ -0,0 +1,19 @@ +package de.hsmannheim.faktura.dokumente.exception; + +/** + * Wird geworfen wenn ein Dokument anhand seiner Nummer nicht + * im Repository gefunden werden kann. + */ +public class DokumentNotFoundException extends RuntimeException { + + private final String dokumentNummer; + + public DokumentNotFoundException(String dokumentNummer) { + super("Dokument nicht gefunden: " + dokumentNummer); + this.dokumentNummer = dokumentNummer; + } + + public String getDokumentNummer() { + return dokumentNummer; + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/exception/DuplicateDocumentException.java b/src/main/java/de/hsmannheim/faktura/dokumente/exception/DuplicateDocumentException.java new file mode 100644 index 0000000..27bbf8d --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/exception/DuplicateDocumentException.java @@ -0,0 +1,26 @@ +package de.hsmannheim.faktura.dokumente.exception; + +/** + * Wird geworfen wenn ein Folgedokument für ein bereits überführtes Dokument + * erneut erzeugt werden soll (PH-DP-05). + * + * Beispiel: Es wird versucht, eine zweite AB für dasselbe Angebot zu erzeugen. + */ +public class DuplicateDocumentException extends RuntimeException { + + private final String quellDokumentNummer; + + public DuplicateDocumentException(String quellDokumentNummer) { + super("Für Dokument " + quellDokumentNummer + " existiert bereits ein Folgedokument."); + this.quellDokumentNummer = quellDokumentNummer; + } + + public DuplicateDocumentException(String message, String quellDokumentNummer) { + super(message); + this.quellDokumentNummer = quellDokumentNummer; + } + + public String getQuellDokumentNummer() { + return quellDokumentNummer; + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/exception/ValidationException.java b/src/main/java/de/hsmannheim/faktura/dokumente/exception/ValidationException.java new file mode 100644 index 0000000..3f74a98 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/exception/ValidationException.java @@ -0,0 +1,27 @@ +package de.hsmannheim.faktura.dokumente.exception; + +import java.util.List; + +/** + * Wird geworfen wenn Pflichtangaben einer Rechnung fehlen (PH-DP-09). + * Enthält eine Liste aller Validierungsfehler. + */ +public class ValidationException extends RuntimeException { + + private final List fehler; + + public ValidationException(List fehler) { + super("Validierung fehlgeschlagen: " + String.join(", ", fehler)); + this.fehler = List.copyOf(fehler); + } + + public ValidationException(String fehler) { + super("Validierung fehlgeschlagen: " + fehler); + this.fehler = List.of(fehler); + } + + /** Gibt alle Validierungsfehler zurück. */ + public List getFehler() { + return fehler; + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/export/PdfExportService.java b/src/main/java/de/hsmannheim/faktura/dokumente/export/PdfExportService.java new file mode 100644 index 0000000..e02f6fd --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/export/PdfExportService.java @@ -0,0 +1,22 @@ +package de.hsmannheim.faktura.dokumente.export; + +import de.hsmannheim.faktura.dokumente.domain.Dokument; + +import java.nio.file.Path; + +/** + * Schnittstelle für den PDF-Export von Dokumenten (PH-DP-12). + * + * Implementierungen: {@link SimplePdfExportService} (erzeugt Text-PDF ohne externe Bibliothek), + * PdfBoxExportService (vollständiges PDF via Apache PDFBox – benötigt Dependency). + */ +public interface PdfExportService { + + /** + * Exportiert ein Dokument als PDF-Datei. + * + * @param dokument das zu exportierende Dokument + * @return Pfad zur erzeugten PDF-Datei im lokalen Dateisystem + */ + Path export(Dokument dokument); +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/export/SimplePdfExportService.java b/src/main/java/de/hsmannheim/faktura/dokumente/export/SimplePdfExportService.java new file mode 100644 index 0000000..e4fc063 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/export/SimplePdfExportService.java @@ -0,0 +1,125 @@ +package de.hsmannheim.faktura.dokumente.export; + +import de.hsmannheim.faktura.dokumente.domain.*; + +import java.io.*; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Einfache PDF-Export-Implementierung die einen strukturierten Text als .pdf-Datei speichert. + * + * Für echtes PDF: Apache PDFBox-Dependency hinzufügen und PdfBoxExportService verwenden. + * + * Erfüllt PH-DP-12: Datei existiert, ist nicht leer, enthält Dokumentnummer und Dokumenttyp. + * Beim Lieferschein werden keine Preise ausgegeben (PH-DP-07). + */ +public class SimplePdfExportService implements PdfExportService { + + private final Path exportVerzeichnis; + + public SimplePdfExportService(Path exportVerzeichnis) { + this.exportVerzeichnis = exportVerzeichnis; + } + + /** Standard-Konstruktor: exportiert in den Ordner "exports" im aktuellen Verzeichnis. */ + public SimplePdfExportService() { + this(Path.of("exports")); + } + + @Override + public Path export(Dokument dokument) { + try { + Files.createDirectories(exportVerzeichnis); + } catch (IOException e) { + throw new RuntimeException("Export-Verzeichnis konnte nicht erstellt werden: " + exportVerzeichnis, e); + } + + String dateiname = dokument.getDokumentNummer().replace("-", "_") + ".pdf"; + Path zielPfad = exportVerzeichnis.resolve(dateiname); + + String inhalt = erstelleInhalt(dokument); + + try (BufferedWriter writer = Files.newBufferedWriter(zielPfad)) { + writer.write(inhalt); + } catch (IOException e) { + throw new RuntimeException("PDF-Export fehlgeschlagen für: " + dokument.getDokumentNummer(), e); + } + + return zielPfad; + } + + private String erstelleInhalt(Dokument dokument) { + StringBuilder sb = new StringBuilder(); + String separator = "=".repeat(60); + boolean istLieferschein = dokument instanceof Lieferschein; + + sb.append(separator).append("\n"); + sb.append(" ").append(dokument.getTyp().name()).append("\n"); + sb.append(" Nummer: ").append(dokument.getDokumentNummer()).append("\n"); + sb.append(separator).append("\n\n"); + + sb.append("Datum: ").append(dokument.getDatum()).append("\n"); + sb.append("Kunden-ID: ").append(dokument.getKundenId()).append("\n"); + sb.append("Status: ").append(dokument.getStatus()).append("\n"); + + // Typ-spezifische Felder + if (dokument instanceof Auftragsbestaetigung ab) { + sb.append("Angebots-Nr.: ").append(ab.getAngebotsNummer()).append("\n"); + } + if (dokument instanceof Lieferschein ls) { + sb.append("Auftrags-Nr.: ").append(ls.getAuftragsNummer()).append("\n"); + sb.append("Lieferdatum: ").append(ls.getLieferdatum()).append("\n"); + } + if (dokument instanceof Rechnung r) { + sb.append("Lieferschein-Nr.: ").append(r.getLieferscheinNummer()).append("\n"); + sb.append("Rechnungsdatum: ").append(r.getRechnungsdatum()).append("\n"); + if (r.getPflichtangaben() != null) { + RechnungPflichtangaben pa = r.getPflichtangaben(); + sb.append("\n--- Pflichtangaben ---\n"); + sb.append("Leistungserbringer: ").append(pa.getLeistungserbringerName()).append("\n"); + sb.append("Anschrift: ").append(pa.getLeistungserbringerAnschrift()).append("\n"); + sb.append("Steuernummer: ").append(pa.getSteuerNr()).append("\n"); + sb.append("Empfänger: ").append(pa.getEmpfaengerName()).append("\n"); + sb.append("Empf.-Anschrift: ").append(pa.getEmpfaengerAnschrift()).append("\n"); + sb.append("Leistungsdatum: ").append(pa.getLeistungsdatum()).append("\n"); + } + } + + // Positionen + sb.append("\n--- Positionen ---\n"); + sb.append(String.format("%-4s %-30s %6s", "Pos.", "Bezeichnung", "Menge")); + if (!istLieferschein) { + sb.append(String.format(" %12s %8s %12s", "EPreis Netto", "MwSt%", "Gesamt Netto")); + } + sb.append("\n").append("-".repeat(istLieferschein ? 44 : 80)).append("\n"); + + int i = 1; + for (DokumentPosition pos : dokument.getPositionen()) { + sb.append(String.format("%-4d %-30s %6d", + i++, pos.getBezeichnungSnapshot(), pos.getMenge())); + if (!istLieferschein) { + sb.append(String.format(" %12.2f %7.0f%% %12.2f", + pos.getEinzelpreisNettoSnapshot(), + pos.getMwstSatzSnapshot().multiply(java.math.BigDecimal.valueOf(100)), + pos.getNettoGesamt())); + } + sb.append("\n"); + } + + // Summen (nicht für Lieferschein) + if (!istLieferschein) { + sb.append("\n"); + sb.append(String.format("%60s %12.2f EUR%n", "Nettosumme:", dokument.getNettoSumme())); + sb.append(String.format("%60s %12.2f EUR%n", "USt:", dokument.getUstSumme())); + sb.append(String.format("%60s %12.2f EUR%n", "Bruttosumme:", dokument.getBruttoSumme())); + } + + sb.append("\n").append(separator).append("\n"); + sb.append("Exportiert am: ").append(LocalDateTime.now().format( + DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))).append("\n"); + + return sb.toString(); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerDto.java b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerDto.java new file mode 100644 index 0000000..869cecf --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerDto.java @@ -0,0 +1,31 @@ +package de.hsmannheim.faktura.dokumente.lookup; + +/** + * Datentransferobjekt für Kundendaten aus der Kundenverwaltung (Gruppe H). + * Wird für den Dokumentkopf und die Rechnungspflichtangaben benötigt. + */ +public class CustomerDto { + + private final long kundenId; + private final String name; + private final String anschrift; + private final String steuerNr; + + public CustomerDto(long kundenId, String name, String anschrift, String steuerNr) { + this.kundenId = kundenId; + this.name = name; + this.anschrift = anschrift; + this.steuerNr = steuerNr; + } + + public long getKundenId() { return kundenId; } + public String getName() { return name; } + public String getAnschrift() { return anschrift; } + public String getSteuerNr() { return steuerNr; } + + @Override + public String toString() { + return String.format("CustomerDto{id=%d, name='%s', anschrift='%s'}", + kundenId, name, anschrift); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerLookup.java b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerLookup.java new file mode 100644 index 0000000..8a6b9a6 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/CustomerLookup.java @@ -0,0 +1,22 @@ +package de.hsmannheim.faktura.dokumente.lookup; + +import java.util.Optional; + +/** + * Schnittstelle zur Kundenverwaltung (Gruppe H). + * Wird vom DokumentService genutzt um Kundendaten für Dokumentkopf und + * Rechnungspflichtangaben zu lesen. + * + * Implementierung wird durch Gruppe H bereitgestellt. + * Für Tests: FakeCustomerLookup verwenden. + */ +public interface CustomerLookup { + + /** + * Sucht einen Kunden anhand seiner ID. + * + * @param kundenId die zu suchende Kunden-ID + * @return Optional mit CustomerDto wenn gefunden, sonst Optional.empty() + */ + Optional findCustomer(long kundenId); +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductDto.java b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductDto.java new file mode 100644 index 0000000..02eca6f --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductDto.java @@ -0,0 +1,34 @@ +package de.hsmannheim.faktura.dokumente.lookup; + +import java.math.BigDecimal; + +/** + * Datentransferobjekt für Produktdaten aus der Produktverwaltung (Gruppe G). + * Wird für den Preis-Snapshot beim Anlegen von Angebotspositionen verwendet. + */ +public class ProductDto { + + private final long produktId; + private final String bezeichnung; + private final BigDecimal einzelpreisNetto; + private final BigDecimal mwstSatz; + + public ProductDto(long produktId, String bezeichnung, + BigDecimal einzelpreisNetto, BigDecimal mwstSatz) { + this.produktId = produktId; + this.bezeichnung = bezeichnung; + this.einzelpreisNetto = einzelpreisNetto; + this.mwstSatz = mwstSatz; + } + + public long getProduktId() { return produktId; } + public String getBezeichnung() { return bezeichnung; } + public BigDecimal getEinzelpreisNetto() { return einzelpreisNetto; } + public BigDecimal getMwstSatz() { return mwstSatz; } + + @Override + public String toString() { + return String.format("ProductDto{id=%d, bezeichnung='%s', preis=%s, mwst=%s}", + produktId, bezeichnung, einzelpreisNetto, mwstSatz); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductLookup.java b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductLookup.java new file mode 100644 index 0000000..7e57944 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/lookup/ProductLookup.java @@ -0,0 +1,21 @@ +package de.hsmannheim.faktura.dokumente.lookup; + +import java.util.Optional; + +/** + * Schnittstelle zur Produktverwaltung (Gruppe G). + * Wird vom DokumentService genutzt um Produktdaten für den Preis-Snapshot zu lesen. + * + * Implementierung wird durch Gruppe G bereitgestellt. + * Für Tests: FakeProductLookup verwenden. + */ +public interface ProductLookup { + + /** + * Sucht ein Produkt anhand seiner ID. + * + * @param produktId die zu suchende Produkt-ID + * @return Optional mit ProductDto wenn gefunden, sonst Optional.empty() + */ + Optional findProduct(long produktId); +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/repository/DokumentRepository.java b/src/main/java/de/hsmannheim/faktura/dokumente/repository/DokumentRepository.java new file mode 100644 index 0000000..fdb04a9 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/repository/DokumentRepository.java @@ -0,0 +1,77 @@ +package de.hsmannheim.faktura.dokumente.repository; + +import de.hsmannheim.faktura.dokumente.domain.Dokument; + +import java.util.List; +import java.util.Optional; + +/** + * Repository-Interface für die Persistenz von Dokumenten. + * Kapselt alle Datenhaltungsoperationen (PH-NF-DP-03). + * + * Implementierungen: {@link InMemoryDokumentRepository} (Tests), + * SqliteDokumentRepository (Produktion). + */ +public interface DokumentRepository { + + /** + * Speichert ein neues Dokument oder aktualisiert ein bestehendes. + * + * @param dokument das zu speichernde Dokument + */ + void save(Dokument dokument); + + /** + * Sucht ein Dokument anhand seiner Dokumentnummer. + * + * @param nummer z. B. "A-2026-0001" oder "R-2026-0003" + * @return Optional mit Dokument wenn gefunden + */ + Optional findByNummer(String nummer); + + /** + * Gibt alle Dokumente eines Kunden zurück, sortiert nach Datum und Dokumenttyp. + * (PH-DP-13) + * + * @param kundenId ID des Kunden + * @return Liste aller zugehörigen Dokumente + */ + List findByKunde(long kundenId); + + /** + * Prüft ob für ein gegebenes Quelldokument bereits ein Folgedokument existiert. + * Wird z. B. verwendet um doppelte ABs für dasselbe Angebot zu verhindern (PH-DP-05). + * + * @param quellDokumentNummer Nummer des Quelldokuments + * @return true wenn ein Folgedokument existiert + */ + boolean existsFolgedokument(String quellDokumentNummer); + + /** + * Prüft ob ein Produkt in mindestens einem Dokument referenziert ist. + * Schützt Stammdaten vor Löschung (PH-DP-15). + * + * @param produktId die zu prüfende Produkt-ID + * @return true wenn das Produkt referenziert wird + */ + boolean existsReferencedProdukt(long produktId); + + /** + * Prüft ob ein Kunde in mindestens einem Dokument referenziert ist. + * Schützt Stammdaten vor Löschung (PH-DP-15). + * + * @param kundenId die zu prüfende Kunden-ID + * @return true wenn der Kunde referenziert wird + */ + boolean existsReferencedKunde(long kundenId); + + /** + * Gibt die höchste bisher vergebene laufende Nummer für einen Typ + * im angegebenen Jahr zurück. Wird vom Nummernkreisgenerator genutzt. + * + * @param praefix z. B. "A", "AB", "LS", "R" + * @param jahr z. B. 2026 + * @return die höchste laufende Nummer oder 0 wenn noch keine existiert + */ + int findMaxLaufendeNummer(String praefix, int jahr); +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/repository/InMemoryDokumentRepository.java b/src/main/java/de/hsmannheim/faktura/dokumente/repository/InMemoryDokumentRepository.java new file mode 100644 index 0000000..465ceb8 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/repository/InMemoryDokumentRepository.java @@ -0,0 +1,97 @@ +package de.hsmannheim.faktura.dokumente.repository; + +import de.hsmannheim.faktura.dokumente.domain.*; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * In-Memory-Implementierung des DokumentRepository für Unit-Tests. + * Kein Datenbankzugriff – alle Daten liegen im Arbeitsspeicher. + * + * Verwendung in Tests: FakeProductLookup + InMemoryDokumentRepository (MT-DP-01 ff.) + */ +public class InMemoryDokumentRepository implements DokumentRepository { + + /** Alle gespeicherten Dokumente, indexiert nach Dokumentnummer. */ + private final Map store = new LinkedHashMap<>(); + + @Override + public void save(Dokument dokument) { + if (dokument == null || dokument.getDokumentNummer() == null) { + throw new IllegalArgumentException("Dokument und Dokumentnummer dürfen nicht null sein."); + } + store.put(dokument.getDokumentNummer(), dokument); + } + + @Override + public Optional findByNummer(String nummer) { + return Optional.ofNullable(store.get(nummer)); + } + + @Override + public List findByKunde(long kundenId) { + return store.values().stream() + .filter(d -> d.getKundenId() == kundenId) + .sorted(Comparator + .comparing(Dokument::getDatum) + .thenComparing(d -> d.getTyp().name())) + .collect(Collectors.toList()); + } + + @Override + public boolean existsFolgedokument(String quellDokumentNummer) { + return store.values().stream().anyMatch(d -> { + if (d instanceof Auftragsbestaetigung ab) { + return quellDokumentNummer.equals(ab.getAngebotsNummer()); + } + if (d instanceof Lieferschein ls) { + return quellDokumentNummer.equals(ls.getAuftragsNummer()); + } + if (d instanceof Rechnung r) { + return quellDokumentNummer.equals(r.getLieferscheinNummer()); + } + return false; + }); + } + + @Override + public boolean existsReferencedProdukt(long produktId) { + return store.values().stream() + .flatMap(d -> d.getPositionen().stream()) + .anyMatch(p -> p.getProduktId() == produktId); + } + + @Override + public boolean existsReferencedKunde(long kundenId) { + return store.values().stream() + .anyMatch(d -> d.getKundenId() == kundenId); + } + + @Override + public int findMaxLaufendeNummer(String praefix, int jahr) { + String jahrStr = "-" + jahr + "-"; + return store.keySet().stream() + .filter(nr -> nr.startsWith(praefix + jahrStr)) + .mapToInt(nr -> { + try { + String laufend = nr.substring(nr.lastIndexOf('-') + 1); + return Integer.parseInt(laufend); + } catch (NumberFormatException e) { + return 0; + } + }) + .max() + .orElse(0); + } + + /** Gibt die Anzahl aller gespeicherten Dokumente zurück (für Tests). */ + public int size() { + return store.size(); + } + + /** Löscht alle Dokumente (für Test-Setup). */ + public void clear() { + store.clear(); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentNummernGenerator.java b/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentNummernGenerator.java new file mode 100644 index 0000000..af81462 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentNummernGenerator.java @@ -0,0 +1,62 @@ +package de.hsmannheim.faktura.dokumente.service; + +import de.hsmannheim.faktura.dokumente.repository.DokumentRepository; + +import java.time.LocalDate; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Generiert fortlaufende, eindeutige Dokumentnummern pro Typ und Kalenderjahr. + * + * Format: PRAEFIX-yyyy-nnnn (z. B. A-2026-0001, AB-2026-0042, R-2026-0003) + * + * Der Zähler wird beim Start aus dem Repository initialisiert (PH-DP-02, PH-DP-10). + * Thread-sicher durch AtomicInteger. + */ +public class DokumentNummernGenerator { + + private final String praefix; + private final DokumentRepository repository; + + /** Zähler pro Jahr: Jahr → laufende Nummer. */ + private final ConcurrentHashMap zaehler = new ConcurrentHashMap<>(); + + public DokumentNummernGenerator(String praefix, DokumentRepository repository) { + this.praefix = praefix; + this.repository = repository; + } + + /** + * Generiert die nächste Dokumentnummer für das aktuelle Kalenderjahr. + * Liest beim ersten Aufruf pro Jahr den maximalen Wert aus dem Repository. + * + * @return z. B. "A-2026-0001" + */ + public synchronized String naechsteNummer() { + int jahr = LocalDate.now().getYear(); + AtomicInteger counter = zaehler.computeIfAbsent(jahr, j -> { + int maxBisher = repository.findMaxLaufendeNummer(praefix, j); + return new AtomicInteger(maxBisher); + }); + int laufend = counter.incrementAndGet(); + return String.format("%s-%d-%04d", praefix, jahr, laufend); + } + + /** + * Generiert eine Nummer für ein bestimmtes Jahr (für Tests / Migrationen). + */ + public synchronized String naechsteNummerFuerJahr(int jahr) { + AtomicInteger counter = zaehler.computeIfAbsent(jahr, j -> { + int maxBisher = repository.findMaxLaufendeNummer(praefix, j); + return new AtomicInteger(maxBisher); + }); + int laufend = counter.incrementAndGet(); + return String.format("%s-%d-%04d", praefix, jahr, laufend); + } + + /** Setzt den Zähler zurück – nur für Tests verwenden! */ + public void resetFuerTests() { + zaehler.clear(); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentService.java b/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentService.java new file mode 100644 index 0000000..ff81067 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentService.java @@ -0,0 +1,92 @@ +package de.hsmannheim.faktura.dokumente.service; + +import de.hsmannheim.faktura.dokumente.domain.*; +import de.hsmannheim.faktura.dokumente.exception.*; + +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; + +/** + * Service-Interface für den Dokumentenprozess. + * Die GUI / der Controller ruft ausschließlich diese Methoden auf. + * + * Implementierung: {@link DokumentServiceImpl} + * + * Kette: Angebot → Auftragsbestätigung → Lieferschein → Rechnung + */ +public interface DokumentService { + + /** + * Erstellt ein neues Angebot für einen Kunden (PH-DP-01). + * + * @param kundenId ID des Kunden (muss in Kundenverwaltung existieren) + * @param positionen Liste der gewünschten Positionen (mindestens eine) + * @return das gespeicherte Angebot mit Nummer, Status OFFEN und berechneten Summen + * @throws IllegalArgumentException wenn Kunde nicht existiert oder Positionen leer + */ + Angebot erstelleAngebot(long kundenId, List positionen); + + /** + * Aktualisiert Positionen eines offenen Angebots (PH-DP-03). + * + * @param angebotsNummer Nummer des Angebots + * @param positionen neue Positionen + * @return das aktualisierte Angebot + * @throws IllegalStateException wenn Angebot nicht mehr OFFEN ist + * @throws DokumentNotFoundException wenn Angebot nicht gefunden + */ + Angebot aktualisiereAngebot(String angebotsNummer, List positionen); + + /** + * Erstellt eine Auftragsbestätigung aus einem offenen Angebot (PH-DP-04). + * + * @param angebotsNummer Nummer des Quellangebots + * @return die gespeicherte AB + * @throws IllegalStateException wenn Angebot nicht OFFEN + * @throws DuplicateDocumentException wenn für das Angebot bereits eine AB existiert + * @throws DokumentNotFoundException wenn Angebot nicht gefunden + */ + Auftragsbestaetigung erstelleAuftragsbestaetigung(String angebotsNummer); + + /** + * Erstellt einen Lieferschein aus einer bestätigten AB (PH-DP-06). + * Der Lieferschein enthält keine Preise (PH-DP-07). + * + * @param auftragsNummer Nummer der Quell-AB + * @param lieferdatum Datum der Lieferung + * @return der gespeicherte Lieferschein + * @throws IllegalStateException wenn AB nicht BESTAETIGT + * @throws DokumentNotFoundException wenn AB nicht gefunden + */ + Lieferschein erstelleLieferschein(String auftragsNummer, LocalDate lieferdatum); + + /** + * Erstellt eine Rechnung aus einem gelieferten Lieferschein (PH-DP-08). + * Die Rechnung wird nach dem Speichern sofort festgeschrieben (PH-DP-11). + * + * @param lieferscheinNummer Nummer des Quell-Lieferscheins + * @return die gespeicherte, festgeschriebene Rechnung + * @throws IllegalStateException wenn Lieferschein nicht GELIEFERT + * @throws ValidationException wenn Pflichtangaben fehlen + * @throws DokumentNotFoundException wenn Lieferschein nicht gefunden + */ + Rechnung erstelleRechnung(String lieferscheinNummer); + + /** + * Exportiert ein Dokument als PDF-Datei (PH-DP-12). + * + * @param dokumentNummer Nummer des Dokuments + * @return Pfad zur erzeugten PDF-Datei + * @throws DokumentNotFoundException wenn Dokument nicht gefunden + */ + Path exportiereAlsPdf(String dokumentNummer); + + /** + * Gibt alle Dokumente eines Kunden zurück, sortiert nach Datum (PH-DP-13). + * + * @param kundenId ID des Kunden + * @return Liste aller Dokumente des Kunden + */ + List findByKunde(long kundenId); +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentServiceImpl.java b/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentServiceImpl.java new file mode 100644 index 0000000..0e538c5 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/service/DokumentServiceImpl.java @@ -0,0 +1,323 @@ +package de.hsmannheim.faktura.dokumente.service; + +import de.hsmannheim.faktura.dokumente.domain.*; +import de.hsmannheim.faktura.dokumente.exception.*; +import de.hsmannheim.faktura.dokumente.export.PdfExportService; +import de.hsmannheim.faktura.dokumente.lookup.*; +import de.hsmannheim.faktura.dokumente.repository.DokumentRepository; + +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Hauptimplementierung des Dokumentenprozesses. + * + * Enthält die Geschäftslogik für die Kette: + * Angebot → Auftragsbestätigung → Lieferschein → Rechnung + * + * Keine JavaFX-Abhängigkeit (PH-DP-16) – kann isoliert getestet werden. + */ +public class DokumentServiceImpl implements DokumentService { + + private final DokumentRepository repository; + private final ProductLookup productLookup; + private final CustomerLookup customerLookup; + private final PdfExportService pdfExportService; + private final RechnungPflichtangabenValidator validator; + + /** Nummernkreisgeneratoren pro Dokumenttyp. */ + private final DokumentNummernGenerator angebotGenerator; + private final DokumentNummernGenerator abGenerator; + private final DokumentNummernGenerator lsGenerator; + private final DokumentNummernGenerator rechnungGenerator; + + /** + * Vollständiger Konstruktor für Produktionseinsatz. + * Alle Abhängigkeiten werden injiziert (Dependency Injection). + */ + public DokumentServiceImpl(DokumentRepository repository, + ProductLookup productLookup, + CustomerLookup customerLookup, + PdfExportService pdfExportService) { + this.repository = repository; + this.productLookup = productLookup; + this.customerLookup = customerLookup; + this.pdfExportService = pdfExportService; + this.validator = new RechnungPflichtangabenValidator(); + + this.angebotGenerator = new DokumentNummernGenerator("A", repository); + this.abGenerator = new DokumentNummernGenerator("AB", repository); + this.lsGenerator = new DokumentNummernGenerator("LS", repository); + this.rechnungGenerator = new DokumentNummernGenerator("R", repository); + } + + // ------------------------------------------------------------------------- + // Angebot + // ------------------------------------------------------------------------- + + /** + * Erstellt ein neues Angebot (PH-DP-01, PH-DP-02, PH-DP-14). + */ + @Override + public Angebot erstelleAngebot(long kundenId, List commands) { + if (commands == null || commands.isEmpty()) { + throw new IllegalArgumentException("Mindestens eine Position muss angegeben werden."); + } + + // Kunde prüfen + customerLookup.findCustomer(kundenId) + .orElseThrow(() -> new IllegalArgumentException( + "Kunde mit ID " + kundenId + " nicht gefunden.")); + + // Positionen mit Preis-Snapshot aufbauen (PH-DP-14) + List positionen = erstellePositionenMitSnapshot(commands); + + // Angebot erzeugen + Angebot angebot = new Angebot(); + angebot.setDokumentNummer(angebotGenerator.naechsteNummer()); + angebot.setKundenId(kundenId); + angebot.setDatum(LocalDate.now()); + angebot.setPositionen(positionen); + angebot.berechneSummen(); + + repository.save(angebot); + return angebot; + } + + /** + * Aktualisiert ein offenes Angebot (PH-DP-03). + */ + @Override + public Angebot aktualisiereAngebot(String angebotsNummer, List commands) { + Angebot angebot = ladeAngebot(angebotsNummer); + angebot.pruefeBearbeitbar(); // wirft IllegalStateException wenn nicht OFFEN + + List positionen = erstellePositionenMitSnapshot(commands); + angebot.setPositionen(positionen); + angebot.berechneSummen(); + + repository.save(angebot); + return angebot; + } + + // ------------------------------------------------------------------------- + // Auftragsbestätigung + // ------------------------------------------------------------------------- + + /** + * Erstellt eine Auftragsbestätigung aus einem offenen Angebot (PH-DP-04, PH-DP-05). + */ + @Override + public Auftragsbestaetigung erstelleAuftragsbestaetigung(String angebotsNummer) { + Angebot angebot = ladeAngebot(angebotsNummer); + + // Status-Prüfung (PH-DP-04) + if (angebot.getStatus() != DokumentStatus.OFFEN) { + throw new IllegalStateException( + "Angebot " + angebotsNummer + " ist nicht mehr OFFEN (Status: " + + angebot.getStatus() + "). Auftragsbestätigung kann nicht erzeugt werden."); + } + + // Doppelte AB verhindern (PH-DP-05) + if (repository.existsFolgedokument(angebotsNummer)) { + throw new DuplicateDocumentException(angebotsNummer); + } + + // AB erzeugen – Positionen 1:1 übernehmen + Auftragsbestaetigung ab = new Auftragsbestaetigung(); + ab.setDokumentNummer(abGenerator.naechsteNummer()); + ab.setKundenId(angebot.getKundenId()); + ab.setDatum(LocalDate.now()); + ab.setPositionen(new ArrayList<>(angebot.getPositionen())); + ab.setAngebotsNummer(angebotsNummer); + ab.berechneSummen(); + + // Angebot auf UEBERFUEHRT setzen + angebot.ueberFuehren(); + + repository.save(ab); + repository.save(angebot); // Status-Update speichern + return ab; + } + + // ------------------------------------------------------------------------- + // Lieferschein + // ------------------------------------------------------------------------- + + /** + * Erstellt einen Lieferschein aus einer bestätigten AB (PH-DP-06, PH-DP-07). + * Enthält NUR Menge und Bezeichnung – keine Preise. + */ + @Override + public Lieferschein erstelleLieferschein(String auftragsNummer, LocalDate lieferdatum) { + Auftragsbestaetigung ab = ladeAuftragsbestaetigung(auftragsNummer); + + if (ab.getStatus() != DokumentStatus.BESTAETIGT) { + throw new IllegalStateException( + "Auftragsbestätigung " + auftragsNummer + " hat nicht den Status BESTAETIGT " + + "(aktueller Status: " + ab.getStatus() + ")."); + } + + // Positionen ohne Preis-Snapshot (PH-DP-07) + // Die Positionen werden unverändert übernommen; + // beim Export/DTO werden Preise weggelassen (in PdfBoxExportService gehandhabt) + Lieferschein ls = new Lieferschein(); + ls.setDokumentNummer(lsGenerator.naechsteNummer()); + ls.setKundenId(ab.getKundenId()); + ls.setDatum(LocalDate.now()); + ls.setPositionen(new ArrayList<>(ab.getPositionen())); + ls.setAuftragsNummer(auftragsNummer); + ls.setLieferdatum(lieferdatum != null ? lieferdatum : LocalDate.now()); + // Summen werden NICHT berechnet – Lieferschein hat keine Preise nach außen + + repository.save(ls); + return ls; + } + + // ------------------------------------------------------------------------- + // Rechnung + // ------------------------------------------------------------------------- + + /** + * Erstellt eine Rechnung aus einem gelieferten Lieferschein (PH-DP-08 bis PH-DP-11). + */ + @Override + public Rechnung erstelleRechnung(String lieferscheinNummer) { + Lieferschein ls = ladeLieferschein(lieferscheinNummer); + + if (ls.getStatus() != DokumentStatus.GELIEFERT) { + throw new IllegalStateException( + "Lieferschein " + lieferscheinNummer + " hat nicht den Status GELIEFERT " + + "(aktueller Status: " + ls.getStatus() + ")."); + } + + // Kundendaten für Pflichtangaben holen + CustomerDto kunde = customerLookup.findCustomer(ls.getKundenId()) + .orElseThrow(() -> new IllegalArgumentException( + "Kunde " + ls.getKundenId() + " nicht gefunden.")); + + // Pflichtangaben aufbauen (PH-DP-09) + RechnungPflichtangaben pa = erstellePflichtangaben(kunde, ls.getLieferdatum()); + + // Rechnungsnummer vorab vergeben (PH-DP-10) – BEVOR validate() um Lücken zu vermeiden + String rechnungsNummer = rechnungGenerator.naechsteNummer(); + + // Rechnung aufbauen + Rechnung rechnung = new Rechnung(); + rechnung.setDokumentNummer(rechnungsNummer); + rechnung.setKundenId(ls.getKundenId()); + rechnung.setDatum(LocalDate.now()); + rechnung.setRechnungsdatum(LocalDate.now()); + rechnung.setPositionen(new ArrayList<>(ls.getPositionen())); + rechnung.setLieferscheinNummer(lieferscheinNummer); + rechnung.setPflichtangaben(pa); + rechnung.berechneSummen(); + + // Validierung (PH-DP-09) – wirft ValidationException bei Fehlern + validator.validate(rechnung); + + // Festschreiben (PH-DP-11) + rechnung.festschreiben(); + + repository.save(rechnung); + return rechnung; + } + + // ------------------------------------------------------------------------- + // PDF-Export + // ------------------------------------------------------------------------- + + /** + * Exportiert ein Dokument als PDF (PH-DP-12). + */ + @Override + public Path exportiereAlsPdf(String dokumentNummer) { + Dokument dokument = repository.findByNummer(dokumentNummer) + .orElseThrow(() -> new DokumentNotFoundException(dokumentNummer)); + return pdfExportService.export(dokument); + } + + // ------------------------------------------------------------------------- + // Dokumentübersicht + // ------------------------------------------------------------------------- + + /** + * Gibt alle Dokumente eines Kunden zurück (PH-DP-13). + */ + @Override + public List findByKunde(long kundenId) { + return repository.findByKunde(kundenId); + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden + // ------------------------------------------------------------------------- + + private Angebot ladeAngebot(String nummer) { + Dokument d = repository.findByNummer(nummer) + .orElseThrow(() -> new DokumentNotFoundException(nummer)); + if (!(d instanceof Angebot)) { + throw new IllegalArgumentException(nummer + " ist kein Angebot."); + } + return (Angebot) d; + } + + private Auftragsbestaetigung ladeAuftragsbestaetigung(String nummer) { + Dokument d = repository.findByNummer(nummer) + .orElseThrow(() -> new DokumentNotFoundException(nummer)); + if (!(d instanceof Auftragsbestaetigung)) { + throw new IllegalArgumentException(nummer + " ist keine Auftragsbestätigung."); + } + return (Auftragsbestaetigung) d; + } + + private Lieferschein ladeLieferschein(String nummer) { + Dokument d = repository.findByNummer(nummer) + .orElseThrow(() -> new DokumentNotFoundException(nummer)); + if (!(d instanceof Lieferschein)) { + throw new IllegalArgumentException(nummer + " ist kein Lieferschein."); + } + return (Lieferschein) d; + } + + /** + * Baut DokumentPositionen mit Preis-Snapshot aus PositionCommands auf (PH-DP-14). + */ + private List erstellePositionenMitSnapshot(List commands) { + List positionen = new ArrayList<>(); + for (PositionCommand cmd : commands) { + ProductDto produkt = productLookup.findProduct(cmd.getProduktId()) + .orElseThrow(() -> new IllegalArgumentException( + "Produkt mit ID " + cmd.getProduktId() + " nicht gefunden.")); + + positionen.add(new DokumentPosition( + produkt.getProduktId(), + produkt.getBezeichnung(), + cmd.getMenge(), + produkt.getEinzelpreisNetto(), + produkt.getMwstSatz() + )); + } + return positionen; + } + + /** + * Baut RechnungPflichtangaben aus Kundendaten und Lieferdatum auf. + * Die Leistungserbringer-Daten (Verkäufer) müssten in einer echten Anwendung + * aus einer Unternehmenskonfiguration kommen. + */ + private RechnungPflichtangaben erstellePflichtangaben(CustomerDto kunde, LocalDate lieferdatum) { + RechnungPflichtangaben pa = new RechnungPflichtangaben(); + // Leistungserbringer-Daten (Platzhalter – aus Konfig zu lesen) + pa.setLeistungserbringerName("Faktura GmbH"); + pa.setLeistungserbringerAnschrift("Musterstraße 1, 68163 Mannheim"); + pa.setSteuerNr("12345/67890"); + // Empfänger aus Kundendaten + pa.setEmpfaengerName(kunde.getName()); + pa.setEmpfaengerAnschrift(kunde.getAnschrift() != null ? kunde.getAnschrift() : ""); + pa.setLeistungsdatum(lieferdatum); + return pa; + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/service/PositionCommand.java b/src/main/java/de/hsmannheim/faktura/dokumente/service/PositionCommand.java new file mode 100644 index 0000000..86ba6e1 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/service/PositionCommand.java @@ -0,0 +1,28 @@ +package de.hsmannheim.faktura.dokumente.service; + +/** + * Eingabeobjekt für eine Dokumentposition beim Anlegen eines Angebots. + * Enthält ProduktId und gewünschte Menge – der Rest wird als Snapshot + * aus der Produktverwaltung gelesen. + */ +public class PositionCommand { + + private final long produktId; + private final int menge; + + public PositionCommand(long produktId, int menge) { + if (menge <= 0) { + throw new IllegalArgumentException("Menge muss > 0 sein."); + } + this.produktId = produktId; + this.menge = menge; + } + + public long getProduktId() { return produktId; } + public int getMenge() { return menge; } + + @Override + public String toString() { + return String.format("PositionCommand{produktId=%d, menge=%d}", produktId, menge); + } +} diff --git a/src/main/java/de/hsmannheim/faktura/dokumente/service/RechnungPflichtangabenValidator.java b/src/main/java/de/hsmannheim/faktura/dokumente/service/RechnungPflichtangabenValidator.java new file mode 100644 index 0000000..7e67ac8 --- /dev/null +++ b/src/main/java/de/hsmannheim/faktura/dokumente/service/RechnungPflichtangabenValidator.java @@ -0,0 +1,77 @@ +package de.hsmannheim.faktura.dokumente.service; + +import de.hsmannheim.faktura.dokumente.domain.Rechnung; +import de.hsmannheim.faktura.dokumente.domain.RechnungPflichtangaben; +import de.hsmannheim.faktura.dokumente.exception.ValidationException; + +import java.util.ArrayList; +import java.util.List; + +/** + * Prüft die Pflichtangaben einer Rechnung nach UStG §14 (PH-DP-09). + * + * Fehlende Pflichtangaben verhindern das Speichern der Rechnung + * und liefern Validierungsfehler. + */ +public class RechnungPflichtangabenValidator { + + /** + * Validiert alle Pflichtangaben der Rechnung. + * + * @param rechnung die zu prüfende Rechnung + * @throws ValidationException wenn Pflichtangaben fehlen oder ungültig sind + */ + public void validate(Rechnung rechnung) { + List fehler = new ArrayList<>(); + + // Grundlegende Prüfungen + if (rechnung.getKundenId() <= 0) { + fehler.add("Kunden-ID fehlt oder ungültig."); + } + if (rechnung.getDokumentNummer() == null || rechnung.getDokumentNummer().isBlank()) { + fehler.add("Rechnungsnummer fehlt."); + } + if (rechnung.getRechnungsdatum() == null) { + fehler.add("Rechnungsdatum fehlt."); + } + if (rechnung.getPositionen() == null || rechnung.getPositionen().isEmpty()) { + fehler.add("Rechnung muss mindestens eine Position enthalten."); + } + if (rechnung.getLieferscheinNummer() == null || rechnung.getLieferscheinNummer().isBlank()) { + fehler.add("Referenz auf Lieferschein fehlt."); + } + + // Pflichtangaben-Objekt prüfen + RechnungPflichtangaben pa = rechnung.getPflichtangaben(); + if (pa == null) { + fehler.add("Pflichtangaben-Objekt fehlt vollständig."); + } else { + if (isBlank(pa.getLeistungserbringerName())) { + fehler.add("Name des Leistungserbringers fehlt."); + } + if (isBlank(pa.getLeistungserbringerAnschrift())) { + fehler.add("Anschrift des Leistungserbringers fehlt."); + } + if (isBlank(pa.getSteuerNr())) { + fehler.add("Steuernummer des Leistungserbringers fehlt."); + } + if (isBlank(pa.getEmpfaengerName())) { + fehler.add("Name des Rechnungsempfängers fehlt."); + } + if (isBlank(pa.getEmpfaengerAnschrift())) { + fehler.add("Anschrift des Rechnungsempfängers fehlt."); + } + if (pa.getLeistungsdatum() == null) { + fehler.add("Leistungsdatum fehlt."); + } + } + + if (!fehler.isEmpty()) { + throw new ValidationException(fehler); + } + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +}