feat: Dokumentenprozess Gruppe F - Implementierung und Tests
parent
3485d8f09e
commit
d73ad1457b
|
|
@ -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<DokumentPosition> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DokumentPosition> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DokumentPosition> 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<DokumentPosition> 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<DokumentPosition> getPositionen() {
|
||||||
|
return Collections.unmodifiableList(positionen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPositionen(List<DokumentPosition> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DokumentPosition> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DokumentPosition> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> fehler;
|
||||||
|
|
||||||
|
public ValidationException(List<String> 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<String> getFehler() {
|
||||||
|
return fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<CustomerDto> findCustomer(long kundenId);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ProductDto> findProduct(long produktId);
|
||||||
|
}
|
||||||
|
|
@ -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<Dokument> 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<Dokument> 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);
|
||||||
|
}
|
||||||
|
|
@ -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<String, Dokument> 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<Dokument> findByNummer(String nummer) {
|
||||||
|
return Optional.ofNullable(store.get(nummer));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Dokument> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Integer, AtomicInteger> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PositionCommand> 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<PositionCommand> 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<Dokument> findByKunde(long kundenId);
|
||||||
|
}
|
||||||
|
|
@ -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<PositionCommand> 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<DokumentPosition> 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<PositionCommand> commands) {
|
||||||
|
Angebot angebot = ladeAngebot(angebotsNummer);
|
||||||
|
angebot.pruefeBearbeitbar(); // wirft IllegalStateException wenn nicht OFFEN
|
||||||
|
|
||||||
|
List<DokumentPosition> 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<Dokument> 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<DokumentPosition> erstellePositionenMitSnapshot(List<PositionCommand> commands) {
|
||||||
|
List<DokumentPosition> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue