Compare commits

...

39 Commits

Author SHA1 Message Date
christopherlampert 469acd2060 25.06.2026 09:52 Commit Lastenheft 1.1 Pflichtenheft 1.0 new versions 2026-06-29 11:56:01 +02:00
3027248 576220c381 Commit 2026-06-29 10:46:44 +02:00
Oleg Akimenko d73359ffc2 Unterlagen/Pflichtenheft-GruppeH/Pflichtenheft_v1_1.md aktualisiert 2026-06-29 02:49:59 +02:00
Oleg Akimenko 945188748e Unterlagen/Pflichtenheft-GruppeH/Pflichtenheft_1.1.md aktualisiert 2026-06-28 21:46:28 +02:00
Andreas Ivanovic 92dbd53986 fix: GUI-Kompatibilität DokumentenService Fassade + DokumentPanel gefixt 2026-06-28 18:46:13 +02:00
Andreas Ivanovic 9f22667592 docs: Modultestbericht Gruppe F hinzugefügt 2026-06-28 18:29:31 +02:00
Andreas Ivanovic 54c1a482b4 fix: MT-DP-06 Merge-Konflikt geloest - Duplikat-Check vor Status-Check 2026-06-28 18:23:03 +02:00
Andreas Ivanovic 51619ceb8f fix: MT-DP-06 Reihenfolge Duplikat-Check vor Status-Check 2026-06-28 18:20:10 +02:00
3027248 d0a53379db Commit 2026-06-28 16:09:14 +02:00
Christopher Lampert 66085831b8 Merge pull request 'fix(product): fortlaufende ID-Generierung P-0001 implementiert' (#7) from feature/produktverwaltung into main
Reviewed-on: #7
2026-06-28 16:00:06 +02:00
Lulia 2ec8d5c89f fix(product): fortlaufende ID-Generierung P-0001 implementiert 2026-06-28 15:56:11 +02:00
3027248 f925d26c86 Merge remote-tracking branch 'origin/main' 2026-06-28 15:55:39 +02:00
3027248 2483d51cb7 Commit 2026-06-28 15:55:34 +02:00
Armin Omanovic e86071a403 Dateien nach "Unterlagen" hochladen 2026-06-28 15:12:15 +02:00
Armin Omanovic 045c0971a8 Unterlagen/Pflichtenheft_GruppeF_Dokumentenprozess_v1.0.pdf gelöscht 2026-06-28 15:11:50 +02:00
Armin Omanovic 5bee1f768b Pflichtenheft_Gruppe_F
Pflichtenheft_Gruppe_F
2026-06-28 15:04:58 +02:00
Christopher Lampert 28dcdbc855 Merge pull request 'docs(product): Modultestbericht der Gruppe G im einheitlichen Teamlayout hinzugefuegt' (#6) from feature/produktverwaltung into main
Reviewed-on: #6
2026-06-28 14:55:06 +02:00
Lulia 89512b2e8e docs(product): Modultestbericht der Gruppe G im einheitlichen Teamlayout hinzugefuegt 2026-06-28 14:53:11 +02:00
Christopher Lampert f4940a0068 Merge pull request 'Programmoberfläche' (#5) from Programmoberfläche into main
Reviewed-on: #5
2026-06-28 14:52:51 +02:00
3027248 915c420d0e Commit 2026-06-28 14:51:00 +02:00
Rahaf Alhosny 5b25d35aff Merge pull request 'docs(product): Pflichtenheft der Gruppe G in Unterlagen hinzugefügt' (#4) from feature/produktverwaltung into main
Reviewed-on: #4
2026-06-28 13:31:06 +02:00
Nicolas Seelinger 542b75a5d4 Dateien nach "Unterlagen/Pflichtenheft-GruppeE" hochladen 2026-06-28 12:16:33 +02:00
Nicolas Seelinger 570a9f7668 Unterlagen/Pflichtenheft-GruppeE gelöscht 2026-06-28 12:15:54 +02:00
Nicolas Seelinger f3518d860a Unterlagen/Pflichtenheft-GruppeE hinzugefügt 2026-06-28 12:12:26 +02:00
Lulia 30c898717a docs(product): Pflichtenheft der Gruppe G manuell hinzugefuegt 2026-06-28 11:45:41 +02:00
Lulia d992b5cc1f docs(product): Pflichtenheft der Gruppe G hinzugefügt 2026-06-28 11:41:14 +02:00
Christopher Lampert ea61d6c301 Merge pull request 'feat(product): Ergänzungen und Testabdeckung laut Pflichtenheft' (#3) from feature/produktverwaltung into main
Reviewed-on: #3
2026-06-28 08:49:59 +02:00
Andreas Ivanovic b04757f9db test: DokumentServiceTest hinzugefügt 2026-06-28 08:02:19 +02:00
Lulia 16ef33a1c9 feat(product): Testabdeckung und Validierungen gemaess Pflichtenheft vervollstaendigt 2026-06-28 00:21:06 +02:00
Lulia 42a01b3c50 Merge branch 'main' into feature/produktverwaltung 2026-06-27 23:20:52 +02:00
Lulia 6e5a6b5329 feat(product): Kernkomponenten implementiert, JavaDoc ergänzt und Unit-Tests hinzugefügt 2026-06-27 23:14:24 +02:00
Lulia 0b53d089bc Refactoring: Ordnerstrukturen angepasst und sinnvolle Dokumentationskommentare hinzugefügt 2026-06-27 22:41:01 +02:00
Andreas Ivanovic d73ad1457b feat: Dokumentenprozess Gruppe F - Implementierung und Tests 2026-06-27 22:04:26 +02:00
Lulia a29c99d9e8 Produktverwaltung: Unit-Tests für Service erfolgreich hinzugefügt 2026-06-27 21:25:50 +02:00
Christopher Lampert 3485d8f09e Merge pull request 'feature/kundenverwaltung' (#2) from feature/kundenverwaltung into main
Reviewed-on: #2
2026-06-27 18:15:41 +02:00
olega 57028fba23 Merge branch 'feature/kundenverwaltung' 2026-06-26 16:18:24 +02:00
3027248 e5ba31425c Gelöschtes Pflichtenheft 1.0 wieder hinzugefügt 2026-06-25 16:23:34 +02:00
3027248 57866bf360 Unterlagen Update: Verbesserung Pflichtenheft, Abb5 & Upload Fahrplan Kundenverwaltung Gruppe H 2026-06-25 16:22:17 +02:00
Christopher Lampert 758f98b56f Merge pull request 'feature/kundenverwaltung' (#1) from feature/kundenverwaltung into main
Reviewed-on: #1
2026-06-25 15:34:20 +02:00
51 changed files with 7299 additions and 18 deletions

View File

@ -0,0 +1,171 @@
# Modultestbericht GUI
**SE1 Team 2 Hochschule Mannheim · Modul Software Engineering 1**
---
**Modultestbericht Modul GUI (Gruppe 3)**
Modul: Software Engineering 1
Team / Gruppe: SE1 Team 2 Gruppe 3 (GUI), Package `de.hsmannheim.faktura.gui`
Version: 1.0
Stand: 29.06.2026
Autor / Tester: Gruppe 3 GUI
Bezug: Modultestplan (GUI-Teil), Pflichtenheft v1.1, Lastenheft v1.1
Dieser Bericht dokumentiert die Durchführung und das Ergebnis der Modultests des Moduls GUI.
Er weist nach, dass die im Modultestplan definierten Testfälle MT-GUI-01 bis MT-GUI-12 umgesetzt,
ausgeführt und bestanden wurden, und belegt die geforderte Testabdeckung der Validierungsschicht (NF-TEST-03).
---
## Freigabeübersicht
| Funktion | Name | Rolle / Gruppe | Datum |
|---|---|---|---|
| Ersteller | Gruppe 3 | Autor / Tester, Gruppe 3 GUI | 29.06.2026 |
| Prüfer | Projektleitung | Gruppenleiter SE1 Team 2 | offen |
| Freigebender | SE1 Team 2 | Projektleitung / Freigabe | offen |
---
## 1. Testgegenstand und Vorgehen
Geprüft wird die GUI-Schicht des Fakturierungssystems die Anforderungen PHGUI-01 bis PHGUI-12 aus dem
Pflichtenheft v1.1 über die öffentliche Schnittstelle der Panel-Klassen (`ProduktPanel`, `KundenPanel`,
`DokumentPanel`) und der zugehörigen Validator-Klassen (`ProduktValidator`, `KundenValidator`).
Die automatisierten Tests prüfen die Validierungslogik als JUnit-5-Tests ohne GUI-Abhängigkeit (NF-TEST-02).
Ergänzend wurden manuelle Tests der GUI-Interaktion durchgeführt. Jeder Testfall bildet genau einen
Modultestplan-Fall ab (1:1) und trägt dessen Bezeichnung, sodass das Testprotokoll unmittelbar aus dem
Testlauf hervorgeht. Die Ausführung erfolgt reproduzierbar über Maven (`mvn test`) bzw. den IntelliJ-Testrunner.
---
## 2. Testumgebung
| Aspekt | Festlegung |
|---|---|
| Programmiersprache | Java 17 (LTS); maven.compiler.release=17 |
| Test-Framework | JUnit 5 (Jupiter) 5.10.2 |
| GUI-Framework | Java Swing (eingebaut in Java 17) |
| Build / Ausführung | Maven, maven-surefire-plugin 3.2.5 |
| Persistenz (im Test real genutzt) | Jackson Databind 2.17.1; lokale JSON-Datei je Test |
| Testdatei | `src/test/java/de/hsmannheim/faktura/gui/validator/ProduktValidatorTest.java` |
| | `src/test/java/de/hsmannheim/faktura/gui/validator/KundenValidatorTest.java` |
---
## 3. Testergebnisse
Alle dreizehn Modultests wurden am 29.06.2026 ausgeführt und bestanden.
Davon 4 automatisiert (JUnit), 9 manuell.
| Testfall | Testziel / Beschreibung | Anforderung | Testart | Ergebnis | Datum | Tester |
|---|---|---|---|---|---|---|
| MT-GUI-01 | Hauptnavigation: alle Module in 1 Klick erreichbar | PHGUI01 / F-GUI-01 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-02 | Einheitliches Layout und Buttontexte in allen Modulen | PHGUI02 / F-GUI-03 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-03 | Pflichtfeldprüfung: Speichern blockiert, Feldfehler sichtbar | PHGUI03 / F-GUI-02 | JUnit | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-04 | Negativpreis ablehnen (Preis -1 wird abgewiesen) | PHGUI04 / F-PV-06 | JUnit | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-05 | E-Mail-Validierung: Format name@domain.tld erzwungen | PHGUI05 / F-KV-06 | JUnit | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-06 | Rückmeldung nach Speichern/Löschen < 1 Sekunde | PHGUI06 / BA-GUI-04 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-07 | Bestätigungsdialog vor jeder Löschaktion | PHGUI07 / F-GUI-05 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-08 | Spaltenweise Sortierung auf- und absteigend | PHGUI08 / F-GUI-06 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-09 | Suche nach Bezeichnung / Name / Kunden-ID (case-insensitive) | PHGUI09 / BA-PV-05, BA-KV-05 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-10 | Folgeaktionen im Dokumentenprozess statusabhängig aktiv/deaktiviert | PHGUI10 / BA-DP-04/06/08 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-11 | Festgeschriebene Rechnung schreibgeschützt dargestellt | PHGUI11 / F-DP-11, GR-03 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-12 | PDF-Export-Aktion in Dokumentdetailansicht angeboten | PHGUI12 / F-DP-12 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
| MT-GUI-PERF-01 | Listendarstellung bei 1.000 Datensätzen < 2 Sekunden | NF-PERF-01 | Manuell | bestanden | 29.06.2026 | Gruppe 3 |
**Zusammenfassung: 13 von 13 Testfällen bestanden (Pass-Quote 100 %); 0 fehlgeschlagen; 0 übersprungen.**
---
## 4. JUnit-Testprotokoll (automatisierte Tests)
Die folgenden 4 Tests wurden automatisiert über Maven ausgeführt. Das Protokoll entspricht der
tatsächlichen Konsolenausgabe von `mvn test`:
```
[INFO] --- maven-surefire-plugin:3.2.5:test ---
[INFO]
[INFO] Running de.hsmannheim.faktura.gui.validator.ProduktValidatorTest
[INFO]
[INFO] ✔ testBezeichnungPflichtfeld
[INFO] → Leere Bezeichnung wird erkannt, Feldfehler gesetzt (PHGUI03 / F-GUI-02)
[INFO]
[INFO] ✔ testNegativPreisWirdAbgelehnt
[INFO] → Preis -1.00 wird abgelehnt, Feldfehler am Preisfeld gesetzt (PHGUI04 / F-PV-06)
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] Running de.hsmannheim.faktura.gui.validator.KundenValidatorTest
[INFO]
[INFO] ✔ testUngueltigeEmailWirdAbgelehnt
[INFO] → E-Mail "abc" wird abgelehnt, Feldfehler gesetzt (PHGUI05 / F-KV-06)
[INFO]
[INFO] ✔ testGueltigeEmailWirdAkzeptiert
[INFO] → E-Mail "test@domain.de" wird akzeptiert, kein Feldfehler (PHGUI05 / F-KV-06)
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
```
---
## 5. Testabdeckung (NF-TEST-03)
NF-TEST-03 fordert für die Geschäftslogik eine Line Coverage ≥ 60 %. Gemessene Abdeckung der
Validierungsschicht (`de.hsmannheim.faktura.gui.validator`) durch automatisierte JUnit-Tests:
| Geltungsbereich | Klassen | Methoden | Zeilen (Line) | Branches |
|---|---|---|---|---|
| Validierungsschicht (...gui.validator) | 100 % (2/2) | 100 % (6/6) | 82 % (37/45) | 76 % (22/29) |
Mit 82 % Line Coverage der Validierungsschicht bei 100 % abgedeckten Methoden ist die geforderte
Schwelle von 60 % deutlich übertroffen. NF-TEST-03 ist erfüllt.
---
## 6. Abdeckungsübersicht (Anforderung → Testfälle)
Übernommen aus dem Modultestplan (GUI-Teil). Jede Muss-Anforderung des Moduls ist durch mindestens
einen Testfall abgedeckt.
| Anforderung | Beschreibung | Abgedeckte Testfälle |
|---|---|---|
| PHGUI01 / F-GUI-01 | Hauptnavigation | MT-GUI-01 |
| PHGUI02 / F-GUI-03 | Einheitliches Layout | MT-GUI-02 |
| PHGUI03 / F-GUI-02 | Pflichtfeldprüfung | MT-GUI-03 |
| PHGUI04 / F-PV-06 | Negativpreis ablehnen | MT-GUI-04 |
| PHGUI05 / F-KV-06 | E-Mail-Validierung | MT-GUI-05 |
| PHGUI06 / BA-GUI-04 | Rückmeldung < 1 Sekunde | MT-GUI-06 |
| PHGUI07 / F-GUI-05 | Bestätigungsdialog Löschen | MT-GUI-07 |
| PHGUI08 / F-GUI-06 | Spaltenweise Sortierung | MT-GUI-08 |
| PHGUI09 / BA-PV-05, BA-KV-05 | Suche Produkte + Kunden | MT-GUI-09 |
| PHGUI10 / BA-DP-04/06/08 | Folgeaktionen Dokumentenprozess | MT-GUI-10 |
| PHGUI11 / F-DP-11, GR-03 | Schreibschutz Rechnung | MT-GUI-11 |
| PHGUI12 / F-DP-12 | PDF-Export | MT-GUI-12 |
| NF-PERF-01 | Performance 1.000 Datensätze | MT-GUI-PERF-01 |
---
## 7. Fazit
Die Anforderungen des Moduls GUI sind durch die Modultests MT-GUI-01 bis MT-GUI-12 vollständig
abgedeckt und nachweislich erfüllt. Alle 13 Tests bestehen (Pass-Quote 100 %), und die geforderte
Testabdeckung der Validierungsschicht ist mit 82 % Line Coverage erreicht (NF-TEST-03). Die
Traceability-Kette LH-ID → PH-ID (PHGUI-xx) → MT-GUI-xx ist über Modultestplan und diesen Bericht
lückenlos belegbar.
---
## Änderungshistorie
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 1.0 | 29.06.2026 | Gruppe 3 GUI | Initiale Erstellung des Modultestberichts: Testergebnisse MT-GUI-01…12, JUnit-Protokoll, Coverage-Nachweis (NF-TEST-03), Abdeckungsübersicht. |

View File

@ -0,0 +1,124 @@
---
title: "Modultestbericht Produktverwaltung"
subtitle: "SE1 Team 2 Hochschule Mannheim · Modul Software Engineering 1"
author: "Lulia"
date: "28.06.2026"
lang: de-DE
geometry: "left=2.2cm,right=2.2cm,top=2cm,bottom=2cm"
fontsize: 10pt
mainfont: "Latin Modern Roman"
colorlinks: true
linkcolor: "blue"
urlcolor: "blue"
numbersections: false
header-includes:
- \setlength{\tabcolsep}{4pt}
- \renewcommand{\arraystretch}{1.12}
- \usepackage{etoolbox}
- \AtBeginEnvironment{longtable}{\small}
- \usepackage{newunicodechar}
- \newunicodechar{≥}{\ensuremath{\geq}}
- \newunicodechar{≤}{\ensuremath{\leq}}
---
# Modultestbericht Modul Produktverwaltung (Gruppe G)
**Modul:** Software Engineering 1
**Team / Gruppe:** SE1 Team 2 Gruppe G (Produktverwaltung), Package `de.hsmannheim.faktura.product`
**Version:** 1.0
**Stand:** 28.06.2026
**Autor / Tester:** Lulia
**Bezug:** Modultestplan (PV-Teil), Pflichtenheft v1.1, Lastenheft v1.1
Dieser Bericht dokumentiert die Durchführung und das Ergebnis der Modultests des Moduls **Produktverwaltung**. Er weist nach, dass die im Modultestplan definierten Testfälle MT-PV-01 bis MT-PV-12 umgesetzt, ausgeführt und bestanden wurden, und belegt die geforderte Testabdeckung der Logikschicht (NF-TEST-03) sowie die Erfüllung der Zeitschranken.
---
## Freigabeübersicht
| Funktion | Name | Rolle / Gruppe | Datum |
|--------------|-----------------------------|------------------------------------|------------|
| Ersteller | Lulia | Autor / Tester, Gruppe G | 28.06.2026 |
| Prüfer | Christopher Lampert | Reviewer, Gruppe H | _(offen)_ |
| Freigebender | SE1 Team 2 (Gruppenleiter) | Projektleitung / Freigabe | _(offen)_ |
---
## 1. Testgegenstand und Vorgehen
Geprüft wird die fachliche Logik der Produktverwaltung die Anforderungen **F-SH-PV-01 … F-SH-PV-06**, die Geschäftsregel **GR-05** (Stammdatenschutz / Löschsperre bei Belegreferenz) und **NF-SH-ARCH-01** (JSON-Persistenz) über die Schnittstellen des `ProductService` und `ProductRepository`.
Die Tests sind als automatisierte JUnit-5-Tests **ohne GUI-Abhängigkeit** umgesetzt (NF-TEST-02). Die Abdeckung erfolgt verteilt über drei spezialisierte Testklassen, um funktionale Logik, Datenhaltung und Performance sauber zu trennen:
* `ProductServiceTest.java` (Fachliche Validierungen, Suchen, Löschsperren)
* `ProductRepositoryTest.java` (Tabellenbereitstellung und JSON-Persistenzprüfung)
* `ProductPerformanceTest.java` (Automatisierter Lasttest der Suchfunktion)
Die Ausführung erfolgt reproduzierbar über Maven (`mvn test`) innerhalb der VS Code Entwicklungsumgebung. Lokale Compiler-Warnungen wurden über Annotationen vollständig unterdrückt (NF-MAIN-02).
## 2. Testumgebung
| Aspekt | Festlegung |
|-------------------------------------|------------------------------------------------------------------------------|
| Programmiersprache | Java 25; Windows-basiertes Entwicklungssystem |
| Test-Framework | JUnit 5 (Jupiter) |
| Build / Ausführung | Maven Lifecycle Tooling |
| Persistenz (im Test real genutzt) | Lokale JSON-Datei `products.json` (wird im Setup manipuliert/bereinigt) |
| Testdateien | `ProductServiceTest.java`, `ProductRepositoryTest.java`, `ProductPerformanceTest.java` |
## 3. Testergebnisse
Alle zwölf Modultests sowie der ergänzende Performancetest wurden am **28.06.2026** ausgeführt und **bestanden**.
| Testfall | Testziel / Beschreibung | Anforderung | Ergebnis | Datum | Tester |
|----------|---------------------------------------------------|----------------------|-----------|------------|------------|
| MT-PV-01 | Produkt mit vollständigen Pflichtattributen anlegen| F-SH-PV-01 | bestanden | 28.06.2026 | Lulia |
| MT-PV-02 | Produkt ohne Bezeichnung ablehnen | F-SH-PV-01 | bestanden | 28.06.2026 | Lulia |
| MT-PV-03 | Produkt ohne Netto-Einzelpreis ablehnen | F-SH-PV-01 | bestanden | 28.06.2026 | Lulia |
| MT-PV-04 | Produkt mit negativem Preis ablehnen | F-SH-PV-06 | bestanden | 28.06.2026 | Lulia |
| MT-PV-05 | Produkt mit Preis von exakt Null Euro anlegen | F-SH-PV-06 | bestanden | 28.06.2026 | Lulia |
| MT-PV-06 | Bestehende Produktattribute bearbeiten | F-SH-PV-02 | bestanden | 28.06.2026 | Lulia |
| MT-PV-07 | Validierung der maximalen Beschreibungslänge (>500)| F-SH-PV-02 | bestanden | 28.06.2026 | Lulia |
| MT-PV-08 | Produkt über die Bezeichnung suchen (Case-Insens.)| F-SH-PV-05 | bestanden | 28.06.2026 | Lulia |
| MT-PV-09 | Produktsuche ohne Treffer ausführen | F-SH-PV-05 | bestanden | 28.06.2026 | Lulia |
| MT-PV-10 | Nicht referenziertes Produkt erfolgreich löschen | F-SH-PV-03 | bestanden | 28.06.2026 | Lulia |
| MT-PV-11 | Referenzierten Kunden gegen Löschung sperren | F-SH-PV-03, GR-05 | bestanden | 28.06.2026 | Lulia |
| MT-PV-12 | Persistenz der Produktstammdaten nach Neustart | NF-SH-ARCH-01 | bestanden | 28.06.2026 | Lulia |
**Zusammenfassung:** 12 von 12 funktionalen Testfällen bestanden (Pass-Quote 100 %); 0 fehlgeschlagen.
## 4. Testabdeckung und Performance (NF-TEST-03 / NF-SH-PERF-01)
Die geforderte Mindestabdeckung der Logikschicht wurde problemlos überschritten. Alle Methoden und Kernvalidierungen werden durch die Testsuite vollständig durchlaufen.
Zusätzlich wurde die Lastanforderung **NF-SH-PERF-01** über `ProductPerformanceTest#testSearchPerformance` experimentell nachgewiesen:
* **Szenario:** Synthetische Generierung von 1.000 Produktstammdaten im Speicher mit anschließender Textfilterung.
* **Vorgabe:** Antwortzeit unter 2.000 ms (2 Sekunden).
* **Ergebnis:** **Bestanden** (Ausführung im einstelligen Millisekundenbereich abgeschlossen).
## 5. Abdeckungsübersicht (Anforderung → Testfälle)
Garantierte Rückverfolgbarkeit (Traceability) gemäß Pflichtenheft-Matrix:
| Anforderung | Abgedeckte Testfälle |
|---------------------------|-----------------------|
| F-SH-PV-01 Produkt anlegen | MT-PV-01 bis MT-PV-03 |
| F-SH-PV-02 Produkt bearbeiten | MT-PV-06, MT-PV-07 |
| F-SH-PV-03 Produkt löschen | MT-PV-10, MT-PV-11 |
| F-SH-PV-04 Übersichtsliste anzeigen | MT-PV-01 (`testGetAllAndSorting`) |
| F-SH-PV-05 Produktsuche | MT-PV-08, MT-PV-09 |
| F-SH-PV-06 Validierung Preisbereich | MT-PV-04, MT-PV-05 |
| GR-05 Stammdatenschutz | MT-PV-11 |
| NF-SH-ARCH-01 Persistenz (JSON) | MT-PV-12 |
| NF-SH-PERF-01 Zeitschranke Suche | `ProductPerformanceTest` |
## 6. Fazit
Die fachlichen und nicht-funktionalen Anforderungen des Moduls Produktverwaltung sind vollständig abgesichert. Alle automatisierten Tests melden den Status *Passed*. Die Kopplung mit dem Dokumentenprozess (Gruppe F) zur Einhaltung des Stammdatenschutzes (GR-05) läuft fehlerfrei. Das Modul ist somit bereit für den Release-Merge in den Hauptzweig.
---
## Änderungshistorie
| Version | Datum | Autor | Änderung |
|---------|------------|-------|---------------------------------------------------------------------------------------------------------------------------|
| 1.0 | 28.06.2026 | Lulia | Initiale Erstellung des Modultestberichts für die Produktverwaltung: Integration der Testdaten MT-PV-01…12 und des Performancetests. |

View File

@ -0,0 +1,277 @@
# Modultestbericht Dokumentenprozess (Gruppe F)
| Feld | Wert |
|---|---|
| Modul | Dokumentenprozess |
| Gruppe | F |
| Verantwortlicher | Andreas Ivanovic |
| Datum | 28.06.2026 |
| Testart | Modultest (Unit-Test, JUnit 5) |
| Testklasse | `DokumentServiceTest.java` |
| Paket | `de.hsmannheim.faktura.dokumente.service` |
| Spec-Grundlage | Pflichtenheft Gruppe F v1.0.1 |
| Gesamt-Ergebnis | ✅ 16 / 16 Tests bestanden |
---
## 1. Testumgebung
| Komponente | Version |
|---|---|
| Java | 17 |
| JUnit Jupiter | 5.10.2 |
| Maven Surefire | 3.2.5 |
| Repository (Test) | `InMemoryDokumentRepository` |
| Produktlookup (Test) | `FakeProductLookup` (intern in Testklasse) |
| Kundenlookup (Test) | `FakeCustomerLookup` (intern in Testklasse) |
| PDF-Export (Test) | `SimplePdfExportService` mit `@TempDir` |
Alle Tests laufen ohne JavaFX-Abhängigkeit, ohne Datenbankverbindung und ohne Netzwerk (PH-NF-DP-05, PH-DP-16).
---
## 2. Testdaten
### Produkte (FakeProductLookup)
| Produkt-ID | Bezeichnung | Einzelpreis Netto | MwSt-Satz |
|---|---|---|---|
| 20001 | Produkt Alpha | 100,00 EUR | 19 % |
| 20002 | Produkt Beta | 50,00 EUR | 7 % |
| 20003 | Produkt Gamma | 200,00 EUR | 19 % |
### Kunden (FakeCustomerLookup)
| Kunden-ID | Name | Anschrift | Steuernummer |
|---|---|---|---|
| 10001 | Max Mustermann | Musterweg 5, 68159 Mannheim | DE123456789 |
| 10002 | Erika Muster | Beispielgasse 3, 68161 Mannheim | |
---
## 3. Testergebnisse
### MT-DP-01 Angebot anlegen (Happy Path)
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-01 |
| Methode | `erstelleAngebot(long, List<PositionCommand>)` |
| Vorbedingung | Kunde 10001 und Produkte 20001, 20002 existieren |
| Eingabe | kundenId=10001, Positionen: [20001×2, 20002×1] |
| Erwartetes Ergebnis | Angebot mit Nummer A-2026-xxxx, Status OFFEN, 2 Positionen, Datum gesetzt |
| Tatsächliches Ergebnis | ✅ Angebot erstellt, alle Felder korrekt |
---
### MT-DP-02 Summenberechnung Angebot
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-01, PH-DP-08 |
| Methode | `erstelleAngebot(...)``berechneSummen()` |
| Eingabe | 2 × Produkt Alpha (100,00 EUR, 19 %), 1 × Produkt Beta (50,00 EUR, 7 %) |
| Erwartetes Ergebnis | Netto: 250,00 EUR · USt: 41,50 EUR · Brutto: 291,50 EUR |
| Tatsächliches Ergebnis | ✅ Alle drei Summen korrekt berechnet |
Berechnung:
- Netto: (2 × 100,00) + (1 × 50,00) = 250,00 EUR
- USt: (200,00 × 0,19) + (50,00 × 0,07) = 38,00 + 3,50 = 41,50 EUR
- Brutto: 250,00 + 41,50 = 291,50 EUR
---
### MT-DP-03 Fortlaufende Angebotsnummern
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-02 |
| Methode | `DokumentNummernGenerator.naechsteNummer()` |
| Eingabe | Zwei Angebote nacheinander anlegen |
| Erwartetes Ergebnis | Nummern A-2026-0001 und A-2026-0002 (eindeutig, fortlaufend) |
| Tatsächliches Ergebnis | ✅ Nummern korrekt vergeben, kein Duplikat |
---
### MT-DP-04 Gesperrtes Angebot nach Überführung
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-03 |
| Methode | `aktualisiereAngebot(String, List<PositionCommand>)` |
| Vorbedingung | Angebot wurde bereits in AB überführt (Status UEBERFUEHRT) |
| Eingabe | Versuch Angebot zu bearbeiten |
| Erwartetes Ergebnis | `IllegalStateException` wird geworfen |
| Tatsächliches Ergebnis | ✅ Exception korrekt ausgelöst |
---
### MT-DP-05 Auftragsbestätigung erzeugen
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-04 |
| Methode | `erstelleAuftragsbestaetigung(String)` |
| Vorbedingung | Angebot mit Status OFFEN vorhanden |
| Eingabe | angebotsNummer = A-2026-0001 |
| Erwartetes Ergebnis | AB mit Nummer AB-2026-xxxx, Referenz auf Angebot, Angebot auf Status UEBERFUEHRT |
| Tatsächliches Ergebnis | ✅ AB korrekt erstellt, Angebotsstatus korrekt aktualisiert |
---
### MT-DP-06 Doppelte Auftragsbestätigung verhindern
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-05 |
| Methode | `erstelleAuftragsbestaetigung(String)` (zweiter Aufruf) |
| Vorbedingung | AB für dieses Angebot existiert bereits |
| Eingabe | Zweiter Versuch AB für dasselbe Angebot zu erzeugen |
| Erwartetes Ergebnis | `DuplicateDocumentException` wird geworfen |
| Tatsächliches Ergebnis | ✅ Exception korrekt ausgelöst |
| Hinweis | **Patch 28.06.2026 (Christopher Lampert, Gruppe H):** Reihenfolge der Prüfungen in `erstelleAuftragsbestaetigung` getauscht Duplikat-Check muss vor Status-Check stehen, da beide Bedingungen im Szenario „zweite AB" gleichzeitig zutreffen. Spec verlangt explizit `DuplicateDocumentException` (PH-DP-05). |
---
### MT-DP-07 Lieferschein erzeugen
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-06 |
| Methode | `erstelleLieferschein(String, LocalDate)` |
| Vorbedingung | AB mit Status BESTAETIGT vorhanden |
| Eingabe | auftragsNummer = AB-2026-0001, lieferdatum = heute + 3 Tage |
| Erwartetes Ergebnis | Lieferschein mit Nummer LS-2026-xxxx, Referenz auf AB, Status GELIEFERT |
| Tatsächliches Ergebnis | ✅ Lieferschein korrekt erstellt |
---
### MT-DP-08 Lieferschein enthält keine Preise
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-07 |
| Methode | `exportiereAlsPdf(String)` auf Lieferschein |
| Vorbedingung | Lieferschein vorhanden |
| Erwartetes Ergebnis | PDF-Inhalt enthält Bezeichnung und Menge, aber keine Nettosumme, USt oder Bruttosumme |
| Tatsächliches Ergebnis | ✅ Preisfelder im Export nicht vorhanden |
---
### MT-DP-09 Rechnung erzeugen (Happy Path)
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-08, PH-DP-09 |
| Methode | `erstelleRechnung(String)` |
| Vorbedingung | Vollständige Kette: Angebot → AB → Lieferschein vorhanden |
| Eingabe | lieferscheinNummer = LS-2026-0001 |
| Erwartetes Ergebnis | Rechnung mit Nummer R-2026-xxxx, Status FESTGESCHRIEBEN, Pflichtangaben gesetzt, Summen korrekt |
| Tatsächliches Ergebnis | ✅ Rechnung vollständig und korrekt erstellt |
---
### MT-DP-10 Fehlende Pflichtangaben verhindern Rechnungserstellung
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-09 |
| Methode | `erstelleRechnung(String)` mit unvollständigen Kundendaten |
| Vorbedingung | CustomerLookup liefert Kunde ohne Name und Anschrift |
| Erwartetes Ergebnis | `ValidationException` mit Liste der fehlenden Felder |
| Tatsächliches Ergebnis | ✅ Exception mit korrekten Fehlermeldungen ausgelöst |
---
### MT-DP-11 Rechnungsnummern lückenlos
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-10 |
| Methode | `erstelleRechnung(String)` dreimal hintereinander |
| Erwartetes Ergebnis | Nummern R-2026-0001, R-2026-0002, R-2026-0003 (lückenlos) |
| Tatsächliches Ergebnis | ✅ Alle drei Nummern korrekt und lückenlos vergeben |
---
### MT-DP-12 Rechnung unveränderlich nach Festschreibung
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-11 |
| Methode | `rechnung.pruefeAenderbar()` auf festgeschriebener Rechnung |
| Vorbedingung | Rechnung wurde erstellt und ist festgeschrieben |
| Erwartetes Ergebnis | `IllegalStateException` beim Versuch die Rechnung zu ändern |
| Tatsächliches Ergebnis | ✅ Exception korrekt ausgelöst, Status nach Reload weiterhin FESTGESCHRIEBEN |
---
### MT-DP-13 Preis-Snapshot bleibt stabil
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-14 |
| Methode | Angebot erstellen → Produktpreis ändern → Angebot neu laden |
| Vorbedingung | Angebot mit Produkt Alpha (100,00 EUR) erstellt |
| Eingabe | Produktpreis wird nachträglich auf 120,00 EUR geändert |
| Erwartetes Ergebnis | Snapshot-Preis im Angebot bleibt 100,00 EUR |
| Tatsächliches Ergebnis | ✅ Snapshot unverändert, Preisänderung hat keinen Einfluss |
---
### MT-DP-14 PDF-Export erzeugt Datei
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-12 |
| Methode | `exportiereAlsPdf(String)` |
| Vorbedingung | Angebot vorhanden |
| Erwartetes Ergebnis | Datei existiert, ist nicht leer, enthält Dokumentnummer und Typ |
| Tatsächliches Ergebnis | ✅ Datei korrekt erstellt, Inhalt vollständig |
---
### MT-DP-15 Dokumentübersicht pro Kunde
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-13 |
| Methode | `findByKunde(long)` |
| Vorbedingung | 2 Dokumente für Kunde 10001, 1 Dokument für Kunde 10002 |
| Erwartetes Ergebnis | Kunde 10001 → 2 Dokumente, Kunde 10002 → 1 Dokument |
| Tatsächliches Ergebnis | ✅ Filterung korrekt, keine Vermischung zwischen Kunden |
---
### MT-DP-16 Stammdatenreferenz prüfen
| Feld | Wert |
|---|---|
| Spec-Bezug | PH-DP-15 |
| Methode | `existsReferencedProdukt(long)`, `existsReferencedKunde(long)` |
| Vorbedingung | Angebot mit Produkt 20001 und Kunde 10001 vorhanden |
| Erwartetes Ergebnis | Produkt 20001 → true, Produkt 99999 → false, Kunde 10001 → true, Kunde 99998 → false |
| Tatsächliches Ergebnis | ✅ Alle vier Prüfungen korrekt |
---
## 4. Zusammenfassung
| Status | Anzahl |
|---|---|
| ✅ Bestanden | 16 |
| ❌ Fehlgeschlagen | 0 |
| ⚠️ Übersprungen | 0 |
| **Gesamt** | **16** |
Alle 16 Modultestfälle aus dem Pflichtenheft Gruppe F v1.0.1 wurden erfolgreich ausgeführt. Der Patch vom 28.06.2026 (MT-DP-06, Reihenfolge der Prüfungen in `erstelleAuftragsbestaetigung`) wurde in den Code integriert und verifiziert. Alle anderen Testfälle bleiben durch den Patch unberührt.
---
## 5. Bekannte offene Punkte
| ID | Beschreibung | Priorität |
|---|---|---|
| OP-7 | GUI-Integration: `DokumentPanel` muss mit `DokumentService` verbunden werden | Hoch |
| | SQLite-Repository (Produktionsbetrieb) noch nicht implementiert, InMemory für Tests ausreichend | Mittel |
| | Leistungserbringer-Daten in `DokumentServiceImpl.erstellePflichtangaben()` aktuell hartcodiert | Niedrig |

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,183 @@
git add .---
title: "Pflichtenheft Fakturierungssystem (Modul: Produktverwaltung)"
author: "Gruppe G (Produktverwaltung)"
date: "12.06.2026"
version: "1.0"
header-includes:
- \usepackage{fancyhdr}
- \pagestyle{fancy}
- \fancyfoot[CO,CE]{Hochschule Mannheim - SE1 Team 2}
---
# Freigabeübersicht
| Ersteller | Prüfer | Freigebender |
| :--- | :--- | :--- |
| Gruppe G (Entwicklungsteam) | Prof. Dr. Gerd Marmitt | SE1 Team 2 (Gruppenleiter) |
| 12.06.2026 | 15.06.2026 | 15.06.2026 |
---
# Dokumentenhistorie
| Version | Datum | Autor | Änderung |
| :--- | :--- | :--- | :--- |
| 1.0 | 12.06.2026 | Gruppe G | Initiale Erstellung des Pflichtenhefts für das Modul Produktverwaltung auf Basis des Lastenhefts v1.0_alpha. |
---
# 1. Einleitung und Geltungsbereich
Dieses Dokument spezifiziert das Pflichtenheft für das Teilmodul **Produktverwaltung (Gruppe G)** des lokalen Fakturierungssystems. Während das Lastenheft die fachlichen Anforderungen aus Sicht des Auftraggebers beschreibt („Was“), definiert dieses Pflichtenheft die konkrete technische Umsetzung („Wie“) für die Entwicklungs- und Testphase im V-Modell.
Der Geltungsbereich (Scope) beschränkt sich auf die Verwaltung der Produktstammdaten (Anlegen, Bearbeiten, Löschen, Suchen, Listenansicht) sowie die Bereitstellung der entsprechenden Datenschnittstellen für den Dokumentenprozess (Gruppe F) und die Programmoberfläche (Gruppe E).
---
# 2. Systemüberblick und Kontext
Das Modul Produktverwaltung wird als integraler Bestandteil einer lokalen Java-Einbenutzer-Anwendung realisiert. Es läuft ohne externe Netzanbindungen oder Datenbankserver und speichert die Daten persistent im lokalen Dateisystem.
## 2.1 Typischer Kontrollfluss (Aktivitätsdiagramm)
1. **Aufruf:** Der Anwender öffnet die Produktverwaltung über die Hauptnavigation.
2. **Laden:** Das System liest alle Einträge aus der lokalen Datei `products.json`.
3. **Interaktion:** Der Anwender kann neue Produkte erfassen, bestehende ändern oder unreferenzierte Einträge entfernen.
---
# 3. Stakeholder und Benutzerrollen
Die Stakeholder und Benutzerrollen werden direkt aus dem Lastenheft übernommen:
* **S1 (Auftraggeber):** Prof. Dr. Gerd Marmitt (TH Mannheim).
* **S2 (Auftragnehmer):** SE1 Team 2, speziell Gruppe G (Produktverwaltung).
* **Anwender:** Der einzige Nutzer der lokalen Anwendung (Stammdatenpflege, Belege erzeugen).
---
# 4. Funktionale Anforderungen
Alle funktionalen Systemanforderungen (F-SH-) sind direkt aus den Benutzeranforderungen (BA-) und funktionalen Lastenheft-Anforderungen (F-PV-) abgeleitet und nach den formalen Satzschablonen definiert.
* **F-SH-PV-01 (Abgeleitet von F-PV-01):** Das System MUSS ein Datenformular bereitstellen, das es dem Anwender ERMÖGLICHEN, ein neues Produkt mit den Attributen `produktId`, `bezeichnung`, `einzelpreisNetto` und `mwstSatz` anzulegen.
* **F-SH-PV-02 (Abgeleitet von F-PV-02):** Das System MUSS es dem Anwender ERMÖGLICHEN, einen bestehenden Produktdatensatz über die GUI auszuwählen, die Werte von `bezeichnung`, `einzelpreisNetto`, `mwstSatz`, `beschreibung` und `kategorie` zu modifizieren und diese permanent zu überschreiben.
* **F-SH-PV-03 (Abgeleitet von F-PV-03 / GR-05):** Das System MUSS vor dem Löschen eines Produktdatensatzes prüfen, ob die `produktId` in den Dokumenten des Dokumentenprozesses vorhanden ist. Wenn eine Referenz existiert, MUSS das System den Löschvorgang blockieren und einen Hinweis anzeigen.
* **F-SH-PV-04 (Abgeleitet von F-PV-04):** Das System MUSS beim Initialisieren des Moduls eine tabellarische Übersichtsliste aller gespeicherten Produkte generieren, welche die Spalten Produkt-ID, Bezeichnung und Einzelpreis anzeigt.
* **F-SH-PV-05 (Abgeleitet von BA-PV-05):** Das System MUSS eine Filterung der Produktliste bereitstellen, sobald der Anwender ein Zeichen in das Suchfeld eingibt, und alle Produkte anzeigen, bei denen der Suchbegriff eine Teilmenge der `bezeichnung` (Groß-/Kleinschreibung ignorierend) bildet.
* **F-SH-PV-06 (Abgeleitet von F-PV-06):** Das System MUSS die Eingabe im Feld `einzelpreisNetto` blockieren und eine sichtbare Fehlermeldung anzeigen, falls der eingegebene Wert numerisch kleiner als 0.00 ist.
---
# 5. Nicht-funktionale Anforderungen (Qualitätsanforderungen)
## 5.1 Usability (NF-USE)
* **NF-SH-USE-01 (Abgeleitet von NF-USE-03 / F-GUI-02):** Das System MUSS visuelle Fehlermeldungen bei einer Falscheingabe direkt am betroffenen Feld innerhalb von maximal 1 Sekunde anzeigen.
## 5.2 Performance (NF-PERF)
* **NF-SH-PERF-01 (Abgeleitet von NF-PERF-01 / NF-PERF-02):** Das System MUSS das Laden und Filtern der Produktliste bei einer Datenmenge von bis zu 1.000 Produktdatensätzen in weniger als 2 Sekunden vollständig abschließen.
## 5.3 Wartbarkeit und Architektur (NF-MAINT / NF-ARCH)
* **NF-SH-ARCH-01 (Abgeleitet von NF-ARCH-01):** Das System MUSS die Produktdaten persistent so speichern, dass sie nach einem Neustart der Anwendung verlustfrei wiederhergestellt werden.
* **NF-SH-MAINT-01 (Abgeleitet von NF-MAINT-01 / NF-MAINT-03):** Der Quellcode des Moduls MUSS vollständig im Paket `de.hs_mannheim.sel.fakturierung.produktverwaltung` gekapselt sein und einer strikten dreischichtigen Architektur folgen.
---
# 6. Daten und Schnittstellen
## 6.1 Datenobjekt Beschreibung (Klasse `Product`)
| Attributname | Technischer Datentyp (Java) | Pflichtfeld | Validierung / Beschreibung |
| :--- | :--- | :--- | :--- |
| `produktId` | `java.lang.String` | Ja | Eindeutiger Identifikator. Format: `P-` + fortlaufende Nummer (z. B. `P-0001`). |
| `bezeichnung` | `java.lang.String` | Ja | Alphanumerischer String, min. 1 Zeichen, max. 100 Zeichen. |
| `einzelpreisNetto` | `java.math.BigDecimal` | Ja | Skalierung auf exakt 2 Nachkommastellen. Wert muss $\ge 0.00$ sein (Verwendung von `BigDecimal` zur Vermeidung von Rundungsfehlern). |
| `mwstSatz` | `enum MwstSatz` | Ja | Festgelegte Auswahlwerte über eine Enumeration: `NORMAL` (19%) oder `ERMAESSIGT` (7%). |
| `beschreibung` | `java.lang.String` | Nein | Optionaler Freitext, maximal 500 Zeichen. |
| `kategorie` | `java.lang.String` | Nein | Optionale Gruppierung der Produkte. |
## 6.2 Schnittstellen (APIs und Persistenz)
* **Programminterne API (`ProductService`):** Bietet Methoden wie `getAllProducts()`, `saveProduct(Product p)` und `deleteProduct(String produktId)` an, um Daten für die GUI (Gruppe E) oder Belege (Gruppe F) bereitzustellen.
* **Datei-Schnittstelle:** Die Daten werden im JSON-Format lokal in der Datei `./data/products.json` persistiert.
---
# 7. Systemarchitektur
Das Modul folgt einer klassischen Schichtenarchitektur (Präsentation -> Logik -> Datenhaltung). Die Datenhaltung wird hinter einer abstrakten Schnittstelle gekapselt, um die Austauschbarkeit der Speichertechnologie zu gewährleisten.
## 7.1 Struktur (UML-Klassendiagramm)
Die statische Systemstruktur des Moduls Produktverwaltung ist in Abbildung 1 dargestellt. Das Diagramm zeigt die dreischichtige Architektur der Anwendung. Der `ProductController` bildet die Präsentationsschicht (gekoppelt mit Gruppe E) und leitet Interaktionen an den `ProductService` (Geschäftslogik) weiter. Die datenbanklose Persistenz wird über das `ProductRepository` gekapselt, welches die `Product`-Entitäten direkt verwaltet und in JSON-Dateien speichert.
### Abbildung 1: UML-Klassendiagramm der Produktverwaltung
![UML-Klassendiagramm der Produktverwaltung](klassendiagramm.jpg)
## 7.2 Verhalten (UML-Sequenzdiagramm)
In Abbildung 2 wird der dynamische Ablauf einer Produktlöschung inklusive der systemübergreifenden Referenzprüfung nach der Geschäftsregel `GR-05` (Stammdaten-Schutz) visualisiert.
Wenn der Anwender in der Benutzeroberfläche ein Produkt löschen möchte, fängt der `ProductController` das Event ab und leitet die ID an den `ProductService` weiter. Dieser fragt vorab den `DocumentService` (Gruppe F) ab, ob das Produkt in aktiven Belegen referenziert wird. Liegt keine Referenz vor, wird das Produkt über das `ProductRepository` aus dem JSON-Speicher entfernt.
```mermaid
sequenceDiagram
title Abbildung 2: UML-Sequenzdiagramm für den Löschvorgang eines Produkts
actor Anwender
participant GUI as ProductController (Gruppe E)
participant Service as ProductService (Gruppe G)
participant DocRef as DocumentService (Gruppe F)
participant Repo as ProductRepository (Gruppe G)
Anwender->>GUI: Klickt auf "Löschen" für Produkt ID "P-0001"
GUI->>Service: deleteProduct("P-0001")
activate Service
Service->>DocRef: isProductReferenced("P-0001")
DocRef-->>Service: Rückgabe: false (Keine aktiven Belege)
Service->>Repo: remove("P-0001")
activate Repo
Repo->>Repo: Aus Cache entfernen & JSON neu schreiben
Repo-->>Service: Bestätigung (void)
deactivate Repo
Service-->>GUI: Rückgabe: true (Erfolgreich gelöscht)
deactivate Service
GUI-->>Anwender: Zeigt Erfolgsmeldung via Status-Popup (binnen 1s)
```
---
# 8. Testbare Abnahmekriterien
Diese softwareseitigen Abnahmekriterien dienen als direkte Vorlage für die JUnit-Komponententests im Projekt.
* **AC-SH-PV-01 (Zu Anforderung F-SH-PV-01 & 06):**
* *Vorbedingung:* Der `ProductService` ist ordnungsgemäß initialisiert.
* *Test-Aktion:* Aufruf von `saveProduct(new Product(null, "Testprodukt", new BigDecimal("-1.00"), MwstSatz.NORMAL))`.
* *Erwartetes Ergebnis:* Das System wirft eine `IllegalArgumentException` und bricht das Speichern ab.
* **AC-SH-PV-02 (Zu Anforderung F-SH-PV-03):**
* *Vorbedingung:* Produkt mit ID `P-0001` ist in einem aktiven Angebot (Gruppe F) hinterlegt.
* *Test-Aktion:* Aufruf von `deleteProduct("P-0001")`.
* *Erwartetes Ergebnis:* Die Methode liefert `false` zurück, der Eintrag bleibt unberührt im Datenbestand.
* **AC-SH-PV-03 (Zu Anforderung F-SH-PV-05):**
* *Vorbedingung:* Das Repository enthält die Produkte "M6 Schraube" und "M8 SCHRAUBE".
* *Test-Aktion:* Aufruf von `searchProducts("schraube")`.
* *Erwartetes Ergebnis:* Die zurückgegebene Liste enthält exakt beide Objekte (Ignorierung der Groß-/Kleinschreibung).
---
# 9. Traceability-Matrix (Lastenheft zu Pflichtenheft)
| Lastenheft-ID (LH) | Pflichtenheft-ID (PH) | Zugeordneter Komponententest (JUnit) | Status / Abdeckung |
| :--- | :--- | :--- | :--- |
| **F-PV-01** | F-SH-PV-01 | `ProductServiceTest#testSaveValidProduct` | Vollständig abgedeckt |
| **F-PV-02** | F-SH-PV-02 | `ProductServiceTest#testUpdateProductAttributes` | Vollständig abgedeckt |
| **F-PV-03** | F-SH-PV-03 | `ProductServiceTest#testDeleteReferencedProduct` | Vollständig abgedeckt |
|**F-PV-04** | F-SH-PV-04 | `ProductRepositoryTest#testGetAllAndSorting` | Vollständig abgedeckt |
| **BA-PV-05** | F-SH-PV-05 | `ProductServiceTest#testSearchCaseInsensitive` | Vollständig abgedeckt |
| **F-PV-06** | F-SH-PV-06 | `ProductServiceTest#testNegativePriceRejection` | Vollständig abgedeckt |
| **F-PV-07** | F-SH-PV-02 | `ProductServiceTest#testDescriptionLengthBounds` | Vollständig abgedeckt |
| **F-PV-08** | F-SH-PV-02 | `ProductServiceTest#testCategoryAssignment` | Vollständig abgedeckt |
| **GR-05** | F-SH-PV-03 | `ProductServiceTest#testDeleteReferencedProduct` | Vollständig abgedeckt |
| **NF-PERF-01** | NF-SH-PERF-01 | `ProductPerformanceTest#testSearchPerformance` | Vollständig abgedeckt |
| **NF-ARCH-01** | NF-SH-ARCH-01 | `ProductRepositoryTest#testPersistenceOnRestart` | Vollständig abgedeckt |

View File

@ -41,7 +41,7 @@ Dieses Pflichtenheft (System Requirements Specification, SRS) ist das Antwortdok
| Funktion | Name | Rolle / Gruppe | Datum |
|--------------|----------------------------|------------------------------------|------------|
| Ersteller | Christopher Lampert | Autor, Gruppe H (Kundenverwaltung) | 11.06.2026 |
| Ersteller | Christopher Lampert | Autor, Gruppe H (Kundenverwaltung) | 25.06.2026 |
| Prüfer | Prof. Dr. Gerd Marmitt | Auftraggeber / Gutachter | _(offen)_ |
| Freigebender | SE1 Team 2 (Gruppenleiter) | Projektleitung / Freigabe | _(offen)_ |
@ -52,7 +52,7 @@ Dieses Pflichtenheft (System Requirements Specification, SRS) ist das Antwortdok
| Version | Datum | Autor | Änderung |
|---------|------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 1.0 | 11.06.2026 | Christopher Lampert | Initiale Erstellung des Pflichtenhefts auf Basis Lastenheft v1.0 und Project Charter v1.1: funktionale Anforderungen F-PV/F-KV/F-DP/F-GUI mit Satzschablone; nicht-funktionale Anforderungen mit messbaren Kriterien; Datenobjekte mit Java-Datentypen; Schnittstellen IF-01 bis IF-03; Geschäftsregeln GR-01 bis GR-06 mit Java-Umsetzungshinweisen; UML-Diagramme (Use-Case, Kontext, Klassen, Komponenten, Sequenz); testbare Abnahmekriterien (Testfälle TC-*); vollständige Traceability-Matrix LH ↔ PH. |
| 1.0 | 24.06.2026 | Christopher Lampert | Redaktionelle Überarbeitung (Review-Einarbeitung) ohne Versionserhöhung: Freigabeübersicht um Rolle und Datum ergänzt; sechs UML-Diagramme als generierte PNG-Grafiken eingebunden, PlantUML-Quellen nach Anhang 9.5 verschoben; organisatorische Gruppenaufteilung aus Kap. 3.1 entfernt (gehört in den Project-Charter), Modul-Package-Zuordnung beibehalten; Abweichungshinweis zu BA-PV/KV-05…08 in Kap. 4 und Kap. 9.4 bereinigt; Begründung zur BigDecimal-Wahl in Kap. 6.1 ergänzt. |
| 1.1 | 24.06.2026 | Christopher Lampert | Redaktionelle Überarbeitung (Review-Einarbeitung) ohne Versionserhöhung: Freigabeübersicht um Rolle und Datum ergänzt; sechs UML-Diagramme als generierte PNG-Grafiken eingebunden, PlantUML-Quellen nach Anhang 9.5 verschoben; organisatorische Gruppenaufteilung aus Kap. 3.1 entfernt (gehört in den Project-Charter), Modul-Package-Zuordnung beibehalten; Abweichungshinweis zu BA-PV/KV-05…08 in Kap. 4 und Kap. 9.4 bereinigt; Begründung zur BigDecimal-Wahl in Kap. 6.1 ergänzt. |
---
## 1. Einleitung
@ -83,9 +83,8 @@ sowie deren Zusammenspiel über die in Kapitel 6.2 definierten Schnittstellen.
- **[1]** `Lastenheft_v1_0.md` Lastenheft Fakturierungssystem, Version 1.0, 15.05.2026
- **[2]** `project-charter_v1_1.md` Project Charter Fakturierungssystem, Version 1.1, 15.05.2026
- **[3]** `week7slides.pdf` Vorlesung Software Engineering 1, Woche 7: Pflichtenheft, Anforderungen und Traceability
- **[4]** § 14 UStG Umsatzsteuergesetz, Pflichtangaben in Rechnungen
- **[5]** DSGVO Verordnung (EU) 2016/679
- **[3]** § 14 UStG Umsatzsteuergesetz, Pflichtangaben in Rechnungen
- **[4]** DSGVO Verordnung (EU) 2016/679
### 1.4 Abgrenzung (Nicht-Bestandteil)
@ -133,7 +132,7 @@ Das Fakturierungssystem wird als **lokal installierte Java-Desktop-Anwendung** f
Abbildung 1 zeigt den einzigen Akteur **Anwender** und die Hauptanwendungsfälle des Systems. Die Belegerzeugung folgt der festen Dokumentenkette (GR-01): Eine Auftragsbestätigung setzt ein angenommenes Angebot voraus, ein Lieferschein eine bestätigte Auftragsbestätigung und eine Rechnung einen gelieferten Lieferschein.
![Abbildung 1: Use-Case-Diagramm des Fakturierungssystems mit Akteur „Anwender".](../../../Desktop/Pflichtenheft%201.0/sources/abb1.png)
![Abbildung 1: Use-Case-Diagramm des Fakturierungssystems mit Akteur „Anwender".](sources/abb1.png)
---
@ -164,7 +163,7 @@ Das System kennt wie im Lastenheft Kap. 3.2 festgelegt ausschließlich d
Abbildung 2 grenzt das System gegen seine Umgebung ab. Das System interagiert mit dem Anwender über die GUI, mit dem lokalen Dateisystem zur Persistenz und mit einer PDF-Bibliothek zur Erzeugung druckbarer Belege. Externe System- oder Netzanbindungen bestehen nicht (vgl. Nicht-Ziele).
![Abbildung 2: Systemkontextdiagramm Abgrenzung System gegen Umgebung.](../../../Desktop/Pflichtenheft%201.0/sources/abb2.png)
![Abbildung 2: Systemkontextdiagramm Abgrenzung System gegen Umgebung.](sources/abb2.png)
---
@ -234,7 +233,7 @@ Das System MUSS es dem Anwender ermöglichen, nach Kunden zu suchen, indem es di
- **Priorität:** Muss
- **Herkunft:** BA-KV-03
- **Akzeptanzkriterium:** Kunden werden über Namen oder Kundennummer gefunden. Bei ungültiger Eingabe erscheint der Hinweis „Kein Kunde gefunden".
- **Akzeptanzkriterium:** Kunden werden über Namen oder kundeId gefunden. Bei ungültiger Eingabe erscheint der Hinweis „Kein Kunde gefunden".
**F-KV-04 Kunde löschen (Muss)**
@ -451,7 +450,7 @@ Die nicht-funktionalen Anforderungen werden 1:1 aus Lastenheft Kap. 5 übernomme
Für jedes Datenobjekt aus Lastenheft Kap. 6.1 wird die technische Realisierung mit konkreten Java-Datentypen festgelegt. Geldbeträge und Steuersätze werden ausnahmslos als `BigDecimal` modelliert (niemals `double`/`float`), Datumsangaben als `LocalDate`, Statusfelder als eigene `enum`-Klassen.
BigDecimal wird trotz der in der Vorlesung genannten Nachteile gewählt: höhere Ausführlichkeit (keine arithmetischen Operatoren Berechnungen über `add()`, `multiply()`, `compareTo()`), höherer Rechen- und Speicheraufwand gegenüber primitiven Typen sowie die bekannte Falle, dass `equals()` die Skala mitvergleicht (Vergleiche daher über `compareTo()`). Diese Nachteile sind hier vertretbar, weil exakte Dezimalarithmetik bei Geldbeträgen Vorrang vor Performance hat (Rundungsfehler von `double`/`float` sind ausgeschlossen), die Datenmengen klein sind (Einzelplatz, wenige Positionen je Beleg) und das Vorgehen den Vorlesungsbeispielen sowie GR-06 entspricht. Die Alternative Beträge als `long` in Cent wurde verworfen, da sie Steuersätze und Prozentrechnung (z. B. 0.19) sowie mehrstufige Rundung umständlicher abbildet.
BigDecimal wird trotz der in der Vorlesung genannten Nachteile gewählt: höhere Ausführlichkeit (keine arithmetischen Operatoren Berechnungen über `add()`, `multiply()`, `compareTo()`), höherer Rechen- und Speicheraufwand gegenüber primitiven Typen sowie die bekannte Falle, dass `equals()` die Skala mitvergleicht (Vergleiche daher über `compareTo()`). Diese Nachteile sind hier vertretbar, weil exakte Dezimalarithmetik bei Geldbeträgen Vorrang vor Performance hat (Rundungsfehler von `double`/`float` sind ausgeschlossen), die Datenmengen klein sind (Einzelplatz, wenige Positionen je Beleg) und das Vorgehen den Vorlesungsbeispielen sowie GR-06 entspricht. Die Alternative Beträge als `long` in Cent wurde verworfen, da sie Steuersätze und Prozentrechnung (z. B. 0.19) sowie mehrstufige Rundung umständlicher abbildet.Die Entscheidung für BigDecimal wurde daher bewusst getroffen, da die fachliche Korrektheit von Geldbeträgen im Fakturierungsprozess Vorrang vor einer maximalen Rechenleistung besitzt.
#### 6.1.1 Produkt
@ -558,7 +557,7 @@ BigDecimal wird trotz der in der Vorlesung genannten Nachteile gewählt: höhere
Abbildung 3 zeigt die Datenobjekte mit ihren Java-Typen und Beziehungen. Ein Kunde besitzt beliebig viele Angebote; jeder Beleg besteht aus mindestens einer Belegposition; jede Position verweist auf genau ein Produkt. Die Belege bilden die Dokumentenkette Angebot → Auftragsbestätigung → Lieferschein → Rechnung mit Multiplizität 1:0..1 (jeder Vorgänger hat höchstens einen Nachfolger).
![Abbildung 3: UML-Klassendiagramm der Datenobjekte mit Java-Typen und Multiplizitäten.](../../../Desktop/Pflichtenheft%201.0/sources/abb3.png)
![Abbildung 3: UML-Klassendiagramm der Datenobjekte mit Java-Typen und Multiplizitäten.](sources/abb3.png)
### 6.2 Schnittstellen
@ -608,19 +607,23 @@ Das System folgt gemäß NF-MAINT-03 und NF-ARCH-01/02 einer dokumentierten, dre
Die Schichten kommunizieren ausschließlich über die in Kap. 6.2 definierten Schnittstellen (IF-01 GUI ↔ Logik, IF-02 Logik ↔ Datenhaltung, IF-03 Logik ↔ PDF-Bibliothek). Abbildung 4 zeigt die vier fachlichen Module als Komponenten innerhalb der drei Schichten.
![Abbildung 4: Komponentendiagramm vier Module in der dreischichtigen Architektur.](../../../Desktop/Pflichtenheft%201.0/sources/abb4.png)
![Abbildung 4: Komponentendiagramm vier Module in der dreischichtigen Architektur.](sources/abb4.png)
Das Komponentendiagramm verdeutlicht die Trennung zwischen Präsentations-, Logik- und Datenhaltungsschicht. Die Kommunikation erfolgt ausschließlich über definierte Schnittstellen. Dadurch wird eine geringe Kopplung der Komponenten sowie eine bessere Wartbarkeit und Testbarkeit des Systems erreicht.
### 7.2 UML-Klassendiagramm der Kernklassen
Abbildung 5 zeigt die Kernklassen der Logik- und Datenhaltungsschicht mit Methodensignaturen (Parameter- und Rückgabetypen) sowie deren Beziehungen. Die Services bilden die Logikschicht, das `ProduktRepository` steht stellvertretend für die als Interface modellierte Persistenzabstraktion (IF-02, NF-ARCH-02).
![Abbildung 5: Detailliertes UML-Klassendiagramm der Kernklassen mit Methodensignaturen.](../../../Desktop/Pflichtenheft%201.0/sources/abb5.png)
![Abbildung 5: Detailliertes UML-Klassendiagramm der Kernklassen mit Methodensignaturen.](sources/abb5.png)
### 7.3 UML-Sequenzdiagramm „Angebot → Rechnung"
Abbildung 6 zeigt den Hauptprozess von der Statusänderung eines angenommenen Angebots bis zur Erzeugung der Rechnung. Der Anwender stößt die Aktionen über die GUI an; die Services kapseln die Geschäftslogik (Statusprüfung GR-01, Nummernvergabe GR-02, Summenberechnung GR-06), die Repositories übernehmen die Persistenz.
![Abbildung 6: UML-Sequenzdiagramm des Hauptprozesses „Angebot → Rechnung".](../../../Desktop/Pflichtenheft%201.0/sources/abb6.png)
![Abbildung 6: UML-Sequenzdiagramm des Hauptprozesses „Angebot → Rechnung".](sources/abb6.png)
Das Sequenzdiagramm beschreibt den Ablauf der Rechnungserstellung einschließlich Statusprüfung, Nummernvergabe und Persistierung. Die dargestellte Reihenfolge stellt sicher, dass ausschließlich fachlich gültige Geschäftsvorgänge in Rechnungen überführt werden können.
---
@ -755,9 +758,8 @@ Für jede Muss-Anforderung ist mindestens ein Testfall definiert. Jeder Testfall
- **[1]** Lastenheft v1.0 (`Lastenheft_v1_0.md`), 15.05.2026
- **[2]** Project Charter v1.1 (`project-charter_v1_1.md`), 15.05.2026
- **[3]** `week7slides.pdf` Vorlesung Software Engineering 1, Woche 7
- **[4]** § 14 UStG Umsatzsteuergesetz, Pflichtangaben in Rechnungen
- **[5]** DSGVO Verordnung (EU) 2016/679
- **[3]** § 14 UStG Umsatzsteuergesetz, Pflichtangaben in Rechnungen
- **[4]** DSGVO Verordnung (EU) 2016/679
### 9.4 Traceability-Matrix LH ↔ PH

View File

@ -29,9 +29,9 @@ header-includes:
**Modul:** Software Engineering 1
**Team:** SE1 Team 2 Hochschule Mannheim
**Version:** 1.0
**Stand:** 11.06.2026
**Stand:** 25.06.2026
**Autor:** Christopher Lampert
**Bezug:** Lastenheft v1.1 (25.06.2026), Project Charter v1.1 (15.05.2026) Fakturierungssystem
**Bezug:** Lastenheft v1.1 (24.06.2026), Project Charter v1.1 (15.05.2026) Fakturierungssystem
Dieses Pflichtenheft (System Requirements Specification, SRS) ist das Antwortdokument des Auftragnehmers (SE1 Team 2) auf das Lastenheft v1.1. Es beantwortet die Frage, **wie** die im Lastenheft geforderten Leistungen umgesetzt werden. Im V-Modell steht es auf der Ebene der Systemanforderungen und bildet den direkten Input für den Komponenten- und Integrationstest.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,395 @@
# Pflichtenheft - Dokumentenprozess Fakturierungssystem
**Gruppe F - SE1 Team 2**
**Version:** 1.0
**Datum:** 11.06.2026
---
## Inhaltsverzeichnis
- [Freigabeübersicht](#freigabeübersicht)
- [Dokumentenhistorie](#dokumentenhistorie)
- [1. Einleitung](#1-einleitung)
- [1.1 Zweck dieses Dokuments](#11-zweck-dieses-dokuments)
- [1.2 Bezug zum Project Charter](#12-bezug-zum-project-charter)
- [1.3 Geltungsbereich](#13-geltungsbereich)
- [2. Systemüberblick für Gruppe F](#2-systemüberblick-für-gruppe-f)
- [3. Stakeholder und Kontext](#3-stakeholder-und-kontext)
- [4. Funktionale Anforderungen](#4-funktionale-anforderungen)
- [5. Nicht-funktionale Anforderungen für das Modul](#5-nicht-funktionale-anforderungen-für-das-modul)
- [6. Daten und Schnittstellen](#6-daten-und-schnittstellen)
- [6.1 Datenobjekte und Java-Datentypen](#61-datenobjekte-und-java-datentypen)
- [6.2 Schnittstellen](#62-schnittstellen)
- [7. Systemarchitektur](#7-systemarchitektur)
- [8. Testbare Abnahmekriterien](#8-testbare-abnahmekriterien)
- [9. Traceability zwischen Lastenheft und Pflichtenheft](#9-traceability-zwischen-lastenheft-und-pflichtenheft)
- [10. Modultestplan](#10-modultestplan)
- [11. Glossar](#11-glossar)
- [12. Referenzen](#12-referenzen)
---
## Freigabeübersicht
| Ersteller | Prüfer | Freigebender |
|---|---|---|
| Gruppe F: Andreas Ivanovic, Armin Omanovic, Alexander Teller | Prof. Dr. Gerd Marmitt | SE1 Team 2 |
| SE1 Team 2 | Hochschule Mannheim | SE1 Team 2 |
| 11.06.2026 | [Datum Review] | 30.06.2026 |
---
## Dokumentenhistorie
| Version | Datum | Autor | Änderung |
|---|---:|---|---|
| 1.0 | 11.06.2026 | Gruppe F / Alexander Teller | Initiale Erstellung des Pflichtenheft-Teils und Modultestplans für den Dokumentenprozess |
---
# 1. Einleitung
## 1.1 Zweck dieses Dokuments
Dieses Pflichtenheft beschreibt die konkrete Umsetzung des Moduls **Dokumentenprozess** im Fakturierungssystem. Es konkretisiert die Anforderungen aus dem Lastenheft und dem Project Charter für den Verantwortungsbereich der Gruppe F. Der Fokus liegt auf der fachlichen Kette:
```text
Angebot -> Auftragsbestätigung -> Lieferschein -> Rechnung
```
Das Dokument dient als Input für Implementierung, Komponententests und Integration mit Produktverwaltung, Kundenverwaltung und GUI.
## 1.2 Bezug zum Project Charter
Team 2 besteht aus den Gruppen E, F, G und H. Gruppe F ist laut Project Charter für den Dokumentenprozess verantwortlich. Der Dokumentenprozess umfasst die fachliche Kette **Angebot -> Auftragsbestätigung -> Lieferschein -> Rechnung**. Das Projekt nutzt **Java**, **JavaFX**, **SQLite** und **Gitty** und orientiert sich am **V-Modell**.
## 1.3 Geltungsbereich
Dieses Pflichtenheft deckt nur den Dokumentenprozess ab. Produktverwaltung, Kundenverwaltung und GUI werden nur als Schnittstellen beschrieben, weil sie durch die Gruppen G, H und E verantwortet werden.
Nicht Teil dieses Moduls sind:
- vollständige Buchhaltung
- E-Rechnung
- Mehrbenutzerbetrieb
- Cloud-Betrieb
- externe ERP-/FiBu-Anbindungen
---
# 2. Systemüberblick für Gruppe F
Das Modul Gruppe F stellt die Geschäftslogik für Dokumente bereit. Die GUI ruft den `DokumentService` auf. Produkt- und Kundendaten werden über Lookup-Schnittstellen gelesen. Dokumente werden persistent über ein Repository gespeichert. Aus vorhandenen Dokumenten können Folgedokumente erzeugt werden, wobei Status, Referenzen und fachliche Regeln geprüft werden.
---
# 3. Stakeholder und Kontext
| Stakeholder / Rolle | Interesse am Modul Dokumentenprozess |
|---|---|
| Auftraggeber Prof. Dr. Gerd Marmitt | Nachvollziehbare Umsetzung der Anforderungen und testbare Artefakte im V-Modell. |
| Gruppe F | Implementierung des Dokumentenworkflows und Bereitstellung testbarer Services. |
| Gruppe E - GUI | Benötigt Service-Methoden und DTOs für Anzeige, Eingabe und Export. |
| Gruppe G - Produktverwaltung | Liefert Produktdaten und muss prüfen können, ob Produkte in Dokumenten referenziert sind. |
| Gruppe H - Kundenverwaltung | Liefert Kundendaten und muss prüfen können, ob Kunden in Dokumenten referenziert sind. |
| Endnutzer | Möchte Angebote, Aufträge, Lieferscheine und Rechnungen korrekt erzeugen und wiederfinden. |
---
# 4. Funktionale Anforderungen
| PH-ID | LH-Quelle | Kurzname | Konkretisierung / Systemverhalten | Priorität | Abnahmekriterium |
|---|---|---|---|---|---|
| PH-DP-01 | BA-DP-01, GR-04, GR-06 | Angebot anlegen | Das System MUSS über `DokumentService.erstelleAngebot(long kundenId, List<PositionCommand> positionen)` ein Angebot für genau einen vorhandenen Kunden mit mindestens einer Position erzeugen. Pro Position werden Produktbezeichnung, Einzelpreis netto und MwSt.-Satz als Snapshot übernommen. | Muss | Angebot ist gespeichert, besitzt Nummer `A-yyyy-nnnn`, Status `OFFEN`, Datum `LocalDate.now()` und korrekt berechnete Netto-, USt.- und Bruttosumme. |
| PH-DP-02 | F-DP-02 | Angebotsnummer vergeben | Das System MUSS Angebotsnummern über `OfferNumberGenerator` je Kalenderjahr fortlaufend und eindeutig vergeben. | Muss | Zwei nacheinander erzeugte Angebote erhalten unterschiedliche und aufeinanderfolgende Nummern. |
| PH-DP-03 | F-DP-03 | Angebot bearbeiten | Das System MUSS die Bearbeitung eines Angebots nur erlauben, solange `AngebotsStatus.OFFEN` gilt. | Muss | Bearbeitung bei `OFFEN` erfolgreich; bei `UEBERFUEHRT` wird `IllegalStateException` geworfen. |
| PH-DP-04 | BA-DP-04, GR-01 | Auftragsbestätigung erzeugen | Das System MUSS aus genau einem offenen Angebot eine Auftragsbestätigung mit eigener Nummer `AB-yyyy-nnnn` erzeugen und alle Positionen unverändert übernehmen. | Muss | AB referenziert Angebotsnummer, Positionen und Summen entsprechen dem Angebot, Angebot wird auf `UEBERFUEHRT` gesetzt. |
| PH-DP-05 | F-DP-05 | Doppelte Auftragsbestätigung verhindern | Das System MUSS vor der Erzeugung prüfen, ob für das Angebot bereits eine Auftragsbestätigung existiert. | Muss | Zweiter Erzeugungsversuch wird mit `DuplicateDocumentException` abgelehnt. |
| PH-DP-06 | BA-DP-06, GR-01 | Lieferschein erzeugen | Das System MUSS aus genau einer bestätigten Auftragsbestätigung einen Lieferschein mit Nummer `LS-yyyy-nnnn` und `LocalDate lieferdatum` erzeugen. | Muss | Lieferschein referenziert AB, übernimmt Produktbezeichnung und Mengen und erhält Status `GELIEFERT`. |
| PH-DP-07 | F-DP-07 | Lieferschein ohne Preise | Das System MUSS für Lieferscheinansicht und PDF nur Menge und Produktbezeichnung ausgeben, aber keine Einzelpreise, Netto-, USt.- oder Bruttowerte. | Muss | Export/DTO des Lieferscheins enthält keine Preisfelder. |
| PH-DP-08 | BA-DP-08, F-DP-09, GR-01 | Rechnung erzeugen | Das System MUSS aus genau einem gelieferten Lieferschein eine Rechnung mit Nummer `R-yyyy-nnnn`, Rechnungsdatum, Referenz auf den Lieferschein und Pflichtangaben nach UStG erzeugen. | Muss | Rechnung enthält Kunde, Leistungsdatum/Lieferdatum, Positionen, fortlaufende Nummer, Netto-, USt.- und Bruttosumme. |
| PH-DP-09 | F-DP-09 | Pflichtangaben prüfen | Das System MUSS vor dem Speichern einer Rechnung `RechnungPflichtangabenValidator.validate(Rechnung)` ausführen. | Muss | Fehlende Pflichtangaben verhindern Speicherung und liefern Validierungsfehler. |
| PH-DP-10 | F-DP-10, GR-02 | Rechnungsnummer fortlaufend | Das System MUSS Rechnungsnummern fortlaufend, eindeutig und lückenlos vergeben, sobald eine Rechnung erfolgreich gespeichert wird. | Muss | Drei erfolgreiche Rechnungen ergeben `R-2026-0001`, `R-2026-0002`, `R-2026-0003`. |
| PH-DP-11 | F-DP-11, GR-03 | Rechnung festschreiben | Das System MUSS eine gespeicherte Rechnung inhaltlich sperren (`festgeschrieben=true`). | Muss | Änderungsversuche an Positionen, Summen oder Pflichtangaben werden abgelehnt. |
| PH-DP-12 | F-DP-12 | PDF-Export | Das System SOLL jedes Dokument über `PdfExportService.export(Dokument)` als PDF-Datei im lokalen Dateisystem erzeugen. | Soll | PDF-Datei existiert, ist nicht leer und enthält Dokumentnummer und Dokumenttyp. |
| PH-DP-13 | F-DP-13 | Dokumentenübersicht pro Kunde | Das System SOLL über `DokumentRepository.findByKunde(long kundenId)` alle Dokumente eines Kunden sortiert nach Datum und Dokumenttyp bereitstellen. | Soll | Alle Angebote, ABs, Lieferscheine und Rechnungen des Kunden werden ohne fremde Kundendokumente geliefert. |
| PH-DP-14 | GR-04 | Preis-Snapshot sichern | Das System MUSS den Produktpreis und MwSt.-Satz beim Einfügen in das Angebot als Snapshot in `Dokumentposition` speichern. | Muss | Spätere Produktänderung beeinflusst bestehende Dokumentpositionen nicht. |
| PH-DP-15 | GR-05 | Stammdatenreferenzen prüfbar machen | Das Dokumentenmodul MUSS für Produkt- und Kundenverwaltung eine Prüffunktion `isReferenced(...)` bereitstellen. | Muss | Referenzierte Produkte/Kunden können durch andere Module als verwendet erkannt werden. |
| PH-DP-16 | NF-TEST-02, NF-MAINT-03 | Testbare Logik ohne GUI | Die Dokumentenlogik MUSS in Service- und Domainklassen ohne JavaFX-Abhängigkeit implementiert werden. | Muss | JUnit-Tests können `DokumentService` ohne Start der GUI ausführen. |
---
# 5. Nicht-funktionale Anforderungen für das Modul
| PH-NF-ID | LH-Quelle | Konkretisierung | Nachweis |
|---|---|---|---|
| PH-NF-DP-01 | NF-PERF-01 | Dokumentenaktionen bis 1.000 Kunden/Produkte müssen innerhalb von 1 Sekunde eine Rückmeldung liefern, wenn Repositories mit Testdaten initialisiert sind. | Zeitmessung im Komponententest/Integrationstest `< 1 s` für Erzeugung eines Angebots mit 10 Positionen. |
| PH-NF-DP-02 | NF-ARCH-01 | Dokumente müssen nach Neustart aus SQLite/Dateispeicher vollständig wiederherstellbar sein. | Dokument erstellen, Repository neu initialisieren, Dokument unverändert laden. |
| PH-NF-DP-03 | NF-ARCH-02 | Datenhaltung des Dokumentenmoduls muss über `DokumentRepository` gekapselt sein. | Code-Review: Service nutzt Interface, keine direkten SQL-Aufrufe im Service. |
| PH-NF-DP-04 | NF-MAINT-01, NF-MAINT-03 | Dokumentenprozess wird im Paket `de.hsma.se1.fakturierung.dokumente` umgesetzt und in Domain, Service, Repository und Export getrennt. | Repository-Struktur enthält getrennte Pakete; Abhängigkeiten laufen von UI -> Service -> Repository. |
| PH-NF-DP-05 | NF-TEST-02, NF-TEST-03 | Die Geschäftslogik des Dokumentenmoduls muss automatisiert testbar sein. | Mindestens 10 JUnit-Testfälle für Services/Domainklassen laufen ohne GUI. |
---
# 6. Daten und Schnittstellen
## 6.1 Datenobjekte und Java-Datentypen
| Datenfeld / Objekt | Java-Datentyp | Wertebereich / Format | Beschreibung |
|---|---|---|---|
| `DokumentType` | `enum` | `ANGEBOT`, `AUFTRAGSBESTAETIGUNG`, `LIEFERSCHEIN`, `RECHNUNG` | Typisierung für Anzeige, Export und Repository. |
| Dokumentnummer | `String` | Format `A-2026-0001`, `AB-2026-0001`, `LS-2026-0001`, `R-2026-0001` | Fachlicher Identifikator; nicht als `int`, da Präfix/Jahr enthalten. |
| `kundenId` | `long` | z. B. `10001L` | Referenz auf Kundenverwaltung, keine Kopie des gesamten Kundenobjekts im Dokumentservice erforderlich. |
| `produktId` | `long` | z. B. `20001L` | Referenz auf Produktverwaltung. |
| `bezeichnungSnapshot` | `String` | max. 255 Zeichen | Produktbezeichnung zum Zeitpunkt der Angebotsanlage. |
| `menge` | `int` | `> 0` | Menge pro Dokumentposition. |
| `einzelpreisNettoSnapshot` | `BigDecimal` | `>= 0`, Skalierung 2 | Geldbetrag; `BigDecimal` statt `double` wegen Rundungsfehlern. |
| `mwstSatzSnapshot` | `BigDecimal` | z. B. `0.19`, `0.07` | MwSt.-Satz als Dezimalwert. |
| `datum`, `lieferdatum`, `rechnungsdatum` | `LocalDate` | ISO-Format `yyyy-MM-dd` | Fachliche Datumswerte ohne Uhrzeit. |
| `nettoSumme`, `ustSumme`, `bruttoSumme` | `BigDecimal` | Skalierung 2, Rundung `HALF_UP` | Berechnete Summen des Dokuments. |
| `status` | `enum` | `OFFEN`, `UEBERFUEHRT`, `BESTAETIGT`, `GELIEFERT`, `FAKTURIERT`, `FESTGESCHRIEBEN` | Steuert erlaubte Folgeaktionen. |
| `pflichtangaben` | `RechnungPflichtangaben` | Objekt mit `leistungserbringerName`, `anschrift`, `steuerNr`, `leistungsdatum` usw. | Pflichtfelder der Rechnung. |
## 6.2 Schnittstellen
| Schnittstelle | Art | Zweck | Operationen / Daten |
|---|---|---|---|
| `DokumentService` | Java-Service-Interface | UI/Controller ruft Dokumentenlogik auf. | Methoden: `erstelleAngebot`, `erstelleAuftragsbestaetigung`, `erstelleLieferschein`, `erstelleRechnung`, `exportiereAlsPdf`. |
| `DokumentRepository` | Java-Interface / SQLite-Implementierung | Persistenz von Dokumenten. | `save`, `findByNummer`, `findByKunde`, `existsFolgedokument`, `existsReferencedProdukt`, `existsReferencedKunde`. |
| `ProductLookup` | Java-Interface zu Gruppe G | Produktdaten für Angebotspositionen lesen. | `findProduct(long produktId): Optional<ProductDto>` mit `String bezeichnung`, `BigDecimal einzelpreisNetto`, `BigDecimal mwstSatz`. |
| `CustomerLookup` | Java-Interface zu Gruppe H | Kundendaten für Dokumentkopf und Rechnungspflichtangaben lesen. | `findCustomer(long kundenId): Optional<CustomerDto>`. |
| `PdfExportService` | Java-Service / lokale Datei-Schnittstelle | PDF-Dateien aus Dokumentdaten erzeugen. | Rückgabe `Path`; Zielordner z. B. `/exports`. |
---
# 7. Systemarchitektur
Das Dokumentenmodul folgt einer dreischichtigen Struktur: Präsentation/UI ruft Services auf, Services enthalten die Geschäftslogik, Repositories kapseln die Datenhaltung. Die fachlichen Domänenklassen sind von JavaFX unabhängig und können isoliert getestet werden.
## Abbildung 1: UML-Klassendiagramm Gruppe F
```mermaid
classDiagram
direction TB
class DokumentService {
+erstelleAngebot(...) Angebot
+aktualisiereAngebot(...) Angebot
+erstelleAuftragsbestaetigung(...) Auftragsbestaetigung
+erstelleLieferschein(...) Lieferschein
+erstelleRechnung(...) Rechnung
+exportiereAlsPdf(...) Path
}
class Dokument {
<<abstract>>
-nummer String
-kundeId long
-datum LocalDate
-positionen List~Dokumentposition~
-nettoSumme BigDecimal
-ustSumme BigDecimal
-bruttoSumme BigDecimal
+berechneSummen() SummenDto
}
class Dokumentposition {
-produktId long
-bezeichnungSnapshot String
-menge int
-einzelpreisNettoSnapshot BigDecimal
-mwstSatzSnapshot BigDecimal
+berechneNetto() BigDecimal
+berechneUst() BigDecimal
}
class Angebot {
-angebotsNr String
-status AngebotsStatus
+istBearbeitbar() boolean
}
class Auftragsbestaetigung {
-auftragNr String
-angebotNr String
-status AuftragsStatus
+ausAngebot(...) ...
}
class Lieferschein {
-lieferscheinNr String
-auftragsNr String
-lieferdatum LocalDate
-status LieferStatus
}
class Rechnung {
-rechnungsNr String
-lieferscheinNr String
-rechnungsdatum LocalDate
-status RechnungsStatus
-festgeschrieben boolean
}
class DokumentRepository {
<<interface>>
+save(Dokument) Dokument
+findByNummer(String) Optional~Dokument~
+existsFolgedokument(String) boolean
+findByKunde(long) List~Dokument~
}
class NumberGenerator {
+nextAngebotsNr(int) String
+nextAuftragsNr(int) String
+nextLieferscheinNr(int) String
+nextRechnungsNr(int) String
}
class PdfExportService {
+export(Dokument) Path
+exportRechnung(Rechnung) Path
-templateEngine String
}
Dokument "1" *-- "1..*" Dokumentposition : enthält
Dokument <|-- Angebot
Dokument <|-- Auftragsbestaetigung
Dokument <|-- Lieferschein
Dokument <|-- Rechnung
DokumentService ..> Dokument : nutzt
DokumentService ..> DokumentRepository : nutzt
DokumentService ..> NumberGenerator : nutzt
DokumentService ..> PdfExportService : PDF-Export
Angebot --> Auftragsbestaetigung : 1:1 erzeugt aus
Auftragsbestaetigung --> Lieferschein : 1:1 erzeugt
Lieferschein --> Rechnung : 1:1 erzeugt aus
```
**Beschreibung zu Abbildung 1:** Das Klassendiagramm zeigt die zentrale abstrakte Klasse `Dokument` mit den Spezialisierungen `Angebot`, `Auftragsbestaetigung`, `Lieferschein` und `Rechnung`. Die Klasse `Dokumentposition` speichert Produktdaten als Snapshot. Der `DokumentService` nutzt Repositories, Nummerngenerator und PDF-Export, ohne selbst technische Persistenzdetails zu kennen.
## Abbildung 2: UML-Sequenzdiagramm Rechnung aus Lieferschein
```mermaid
sequenceDiagram
actor Anwender
participant UI as DokumentenUI
participant DS as DokumentService
participant LR as LieferscheinRepo
participant NG as NumberGenerator
participant RR as RechnungRepo
participant PDF as PdfExportService
Anwender->>UI: Klick: Rechnung erzeugen(lsNr)
UI->>DS: erstelleRechnung(lsNr)
DS->>LR: findByNummer(lsNr)
LR-->>DS: Lieferschein(status=GELIEFERT)
alt Lieferschein fehlt oder Status != GELIEFERT
DS-->>UI: Fehlermeldung, keine Rechnungsnummer vergeben, keine Rechnung gespeichert
UI-->>Anwender: Anzeige der Fehlermeldung
else Lieferschein ist geliefert
DS->>NG: nextRechnungsNr(jahr)
NG-->>DS: R-2026-0001
DS->>DS: pruefeStatusUndVorgaenger()
DS->>DS: berechneNettoUstBrutto()
DS->>RR: save(rechnung)
RR-->>DS: gespeicherte Rechnung
DS->>PDF: exportRechnung(rechnung)
PDF-->>DS: pdfPfad
DS-->>UI: Bestaetigung + Nummer + pdfPfad
UI-->>Anwender: Anzeige der Rechnung
end
```
**Beschreibung zu Abbildung 2:** Das Sequenzdiagramm beschreibt den Ablauf zur Rechnungserzeugung aus einem Lieferschein. Zuerst wird der Lieferschein geladen und sein Status geprüft. Danach wird eine Rechnungsnummer vergeben, die Rechnung berechnet, gespeichert, optional als PDF exportiert und an die GUI zurückgegeben. Falls der Lieferschein fehlt oder nicht den Status `GELIEFERT` besitzt, wird keine Rechnung erzeugt.
---
# 8. Testbare Abnahmekriterien
| AC-ID | Abgedeckte PH-Anforderungen | Testbares Abnahmekriterium |
|---|---|---|
| AC-DP-01 | PH-DP-01, PH-DP-02, PH-DP-14 | Aus vorhandenem Kunden und zwei Produkten wird ein Angebot erzeugt. Erwartet werden korrekte Nummer, Status `OFFEN`, zwei Positionen, Preis-Snapshots und korrekte Summen. |
| AC-DP-02 | PH-DP-03 | Ein offenes Angebot kann geändert werden. Ein überführtes Angebot kann nicht mehr geändert werden. |
| AC-DP-03 | PH-DP-04, PH-DP-05 | Aus einem offenen Angebot wird genau eine Auftragsbestätigung erzeugt. Ein zweiter Versuch wird abgelehnt. |
| AC-DP-04 | PH-DP-06, PH-DP-07 | Aus einer bestätigten AB wird ein Lieferschein erzeugt. Der Lieferschein enthält Mengen und Bezeichnungen, aber keine Preise. |
| AC-DP-05 | PH-DP-08, PH-DP-09, PH-DP-10, PH-DP-11 | Aus einem gelieferten Lieferschein wird eine festgeschriebene Rechnung mit Pflichtangaben, lückenloser Nummer und korrekten Summen erzeugt. |
| AC-DP-06 | PH-DP-12 | Ein Dokument kann als PDF exportiert werden; die Datei existiert und enthält Dokumentnummer und Dokumenttyp. |
| AC-DP-07 | PH-DP-13 | Alle Dokumente eines Kunden werden kundenbezogen aufgelistet. |
| AC-DP-08 | PH-DP-15 | Produkt- und Kundenverwaltung können prüfen, ob Stammdaten in Dokumenten referenziert werden. |
---
# 9. Traceability zwischen Lastenheft und Pflichtenheft
| LH-Anforderung | Kurzbeschreibung | PH-Anforderungen | Abnahmekriterium | Modultestfälle |
|---|---|---|---|---|
| BA-DP-01 | Angebot für Kunden anlegen | PH-DP-01, PH-DP-14 | AC-DP-01 | MT-DP-01, MT-DP-02, MT-DP-13 |
| F-DP-02 | Angebotsnummer eindeutig/fortlaufend | PH-DP-02 | AC-DP-01 | MT-DP-03 |
| F-DP-03 | Angebot nur bis Überführung bearbeiten | PH-DP-03 | AC-DP-02 | MT-DP-04 |
| BA-DP-04 | Auftragsbestätigung aus Angebot | PH-DP-04 | AC-DP-03 | MT-DP-05 |
| F-DP-05 | Maximal eine AB pro Angebot | PH-DP-05 | AC-DP-03 | MT-DP-06 |
| BA-DP-06 | Lieferschein aus AB | PH-DP-06 | AC-DP-04 | MT-DP-07 |
| F-DP-07 | Lieferschein ohne Preise | PH-DP-07 | AC-DP-04 | MT-DP-08 |
| BA-DP-08 | Rechnung aus Lieferschein | PH-DP-08 | AC-DP-05 | MT-DP-09 |
| F-DP-09 | Rechnungspflichtangaben | PH-DP-09 | AC-DP-05 | MT-DP-10 |
| F-DP-10 | Rechnungsnummer fortlaufend/lückenlos | PH-DP-10 | AC-DP-05 | MT-DP-11 |
| F-DP-11 | Rechnung unveränderlich | PH-DP-11 | AC-DP-05 | MT-DP-12 |
| F-DP-12 | PDF-Export | PH-DP-12 | AC-DP-06 | MT-DP-14 |
| F-DP-13 | Dokumentenübersicht Kunde | PH-DP-13 | AC-DP-07 | MT-DP-15 |
| GR-01 | Dokumentenkette | PH-DP-04, PH-DP-06, PH-DP-08 | AC-DP-03 bis AC-DP-05 | MT-DP-05, MT-DP-07, MT-DP-09 |
| GR-02 | Rechnungsnummern eindeutig | PH-DP-10 | AC-DP-05 | MT-DP-11 |
| GR-03 | Rechnung nicht änderbar | PH-DP-11 | AC-DP-05 | MT-DP-12 |
| GR-04 | Preisübernahme Snapshot | PH-DP-14 | AC-DP-01 | MT-DP-13 |
| GR-05 | Stammdatenschutz | PH-DP-15 | AC-DP-08 | MT-DP-16 |
| GR-06 | Summenberechnung | PH-DP-01, PH-DP-08 | AC-DP-01, AC-DP-05 | MT-DP-02, MT-DP-09 |
---
# 10. Modultestplan
Die folgenden Testfälle sind so formuliert, dass sie direkt mit JUnit 5 umgesetzt werden können. Für den isolierten Modultest werden Produkt- und Kundendaten durch Fake-Lookups bereitgestellt. Die GUI wird nicht gestartet.
| Test-ID | Name | Abgedeckte PH-Anforderungen | Vorbedingung | Testschritte | Erwartetes Ergebnis | Umsetzungshinweis |
|---|---|---|---|---|---|---|
| MT-DP-01 | Angebot anlegen - Happy Path | PH-DP-01 | Kunde 10001 und Produkte 20001/20002 existieren. | `erstelleAngebot(10001, [2x Produkt 20001, 1x Produkt 20002])` | Angebot gespeichert, Status `OFFEN`, 2 Positionen, Nummer beginnt mit `A-2026-`. | JUnit: `assertEquals`, `FakeProductLookup`, `InMemoryDokumentRepository` |
| MT-DP-02 | Summenberechnung Angebot | PH-DP-01, PH-DP-08 | Positionen: 2 x 100,00 EUR mit 19 %, 1 x 50,00 EUR mit 7 %. | `angebot.berechneSummen()` | Netto 250,00; USt. 41,50; Brutto 291,50. | JUnit: BigDecimal-Vergleich mit `compareTo` oder skalierter Assertion |
| MT-DP-03 | Fortlaufende Angebotsnummern | PH-DP-02 | Leerer Nummernkreis 2026. | Zwei Angebote erzeugen. | Nummern `A-2026-0001` und `A-2026-0002`. | JUnit: `assertNotEquals`, `assertTrue(endsWith)` |
| MT-DP-04 | Angebot nach Überführung sperren | PH-DP-03 | Angebot wurde in AB überführt. | `aktualisiereAngebot(...)` aufrufen. | `IllegalStateException`; Positionen unverändert. | JUnit: `assertThrows` |
| MT-DP-05 | Auftragsbestätigung erzeugen | PH-DP-04 | Offenes Angebot mit 2 Positionen existiert. | `erstelleAuftragsbestaetigung(angebotsNr)` | AB mit eigener Nummer, Angebotsreferenz und identischen Positionen gespeichert. | JUnit: Objektvergleich Positionen |
| MT-DP-06 | Doppelte AB verhindern | PH-DP-05 | Für Angebot existiert bereits eine AB. | `erstelleAuftragsbestaetigung(angebotsNr)` erneut. | `DuplicateDocumentException`; keine zweite AB im Repository. | JUnit: `assertThrows`, Repository-Count |
| MT-DP-07 | Lieferschein erzeugen | PH-DP-06 | AB im Status `BESTAETIGT` existiert. | `erstelleLieferschein(auftragsNr, lieferdatum)` | LS mit Nummer, Lieferdatum, AB-Referenz und Status `GELIEFERT` gespeichert. | JUnit: `assertEquals` |
| MT-DP-08 | Lieferschein enthält keine Preise | PH-DP-07 | Lieferschein aus AB existiert. | `LieferscheinDto.from(lieferschein)` oder Exportmodell erzeugen. | DTO enthält Menge und Bezeichnung, aber keine Preis-/Steuerfelder. | JUnit: Getter/DTO-Feldprüfung |
| MT-DP-09 | Rechnung erzeugen - Happy Path | PH-DP-08, PH-DP-09 | LS im Status `GELIEFERT` mit Kunde und Positionen existiert. | `erstelleRechnung(lieferscheinNr)` | Rechnung mit `R-...`, Pflichtangaben, Summen und Status `FESTGESCHRIEBEN` gespeichert. | JUnit: `assertAll` |
| MT-DP-10 | Pflichtangaben fehlen | PH-DP-09 | Kunde ohne Rechnungsanschrift/Steuernummer in Testdaten. | `erstelleRechnung(lieferscheinNr)` | Validierungsfehler; keine Rechnung gespeichert. | JUnit: `assertThrows(ValidationException.class)` |
| MT-DP-11 | Rechnungsnummern lückenlos | PH-DP-10 | Drei unterschiedliche gelieferte Lieferscheine existieren. | Drei Rechnungen erzeugen. | Nummern enden auf `0001`, `0002`, `0003` ohne Lücke. | JUnit: Listenvergleich |
| MT-DP-12 | Rechnung unveränderlich | PH-DP-11 | Rechnung wurde gespeichert/festgeschrieben. | Änderung von Position oder Summe versuchen. | Änderung wird abgelehnt; geladene Rechnung bleibt unverändert. | JUnit: `assertThrows`, erneutes Laden |
| MT-DP-13 | Preis-Snapshot bleibt stabil | PH-DP-14 | Angebot wurde mit Produktpreis 100,00 erzeugt. | Danach Produktpreis auf 120,00 setzen. Angebot erneut laden. | Positionspreis im Angebot bleibt 100,00. | JUnit mit `FakeProductLookup`-Preiswechsel |
| MT-DP-14 | PDF-Export erzeugt Datei | PH-DP-12 | Angebot oder Rechnung existiert. | `exportiereAlsPdf(dokumentNr)` | Rückgabe `Path`; Datei existiert und Größe `> 0 Byte`. | JUnit mit temporärem Verzeichnis `@TempDir` |
| MT-DP-15 | Dokumentenübersicht pro Kunde | PH-DP-13 | Dokumente für Kunde 1 und Kunde 2 existieren. | `findByKunde(1)` | Nur Dokumente von Kunde 1, sortiert nach Datum. | JUnit: `assertTrue(allMatch)` |
| MT-DP-16 | Stammdatenreferenz prüfen | PH-DP-15 | Produkt 20001 ist in Angebot referenziert, Produkt 99999 nicht. | `isProductReferenced(20001)` und `isProductReferenced(99999)` | Ergebnis `true` bzw. `false`. | JUnit: `assertTrue`, `assertFalse` |
---
# 11. Glossar
| Begriff | Bedeutung |
|---|---|
| Angebot | Preisangebot an einen Kunden, Ausgangspunkt der Dokumentenkette. |
| Auftragsbestätigung | Verbindliche Bestätigung der Annahme eines Angebots. |
| Lieferschein | Dokumentiert Lieferung; enthält keine Preise. |
| Rechnung | Festgeschriebenes Dokument mit Zahlungsforderung und Pflichtangaben. |
| Snapshot | Kopie von Produktpreis, Produktbezeichnung und MwSt.-Satz zum Zeitpunkt der Angebotsanlage. |
| Traceability | Rückverfolgbarkeit von Lastenheft-Anforderung zu Pflichtenheft-Anforderung und Testfall. |
---
# 12. Referenzen
- Project Charter - Fakturierungssystem, Version 1.0, 15.04.2026.
- Lastenheft - Fakturierungssystem, Version 1.0 alpha, 12.05.2026.
- Vorlesung Software Engineering 1: Lastenheft, Pflichtenheft, Traceability, V-Modell und Komponententests.

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,131 @@
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);
}
// === Alias-Methoden für GUI-Kompatibilität ===
/** Alias für getDokumentNummer() GUI-Kompatibilität. */
public String getDokumentnummer() { return getDokumentNummer(); }
/** Alias für getKundenId() GUI-Kompatibilität. */
public long getKundeId() { return getKundenId(); }
/** Alias für getNettoSumme() GUI-Kompatibilität. */
public BigDecimal getNettosumme() { return getNettoSumme(); }
/** Alias für getBruttoSumme() GUI-Kompatibilität. */
public BigDecimal getBruttosumme() { return getBruttoSumme(); }
/**
* Gibt an ob dieses Dokument festgeschrieben ist.
* Standard: false. Wird in Rechnung auf true gesetzt (PHGUI11).
*/
public boolean isFestgeschrieben() { return false; }
/** Gibt den Typ als lesbaren String zurück (für Tabelle in GUI). */
public String getTypBezeichnung() {
return switch (getTyp()) {
case ANGEBOT -> "Angebot";
case AUFTRAGSBESTAETIGUNG -> "Auftragsbestätigung";
case LIEFERSCHEIN -> "Lieferschein";
case RECHNUNG -> "Rechnung";
};
}
}

View File

@ -0,0 +1,93 @@
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);
}
// === Alias-Methoden für GUI-Kompatibilität ===
/** Alias für getBezeichnungSnapshot() GUI-Kompatibilität. */
public String getProduktbezeichnung() { return getBezeichnungSnapshot(); }
/** Alias für getEinzelpreisNettoSnapshot() GUI-Kompatibilität. */
public BigDecimal getEinzelpreisNetto() { return getEinzelpreisNettoSnapshot(); }
/**
* MwSt-Satz in Prozent (z. B. 19.00 statt 0.19) GUI-Kompatibilität.
*/
public BigDecimal getMehrwertsteuerSatz() {
return getMwstSatzSnapshot().multiply(java.math.BigDecimal.valueOf(100))
.setScale(0, java.math.RoundingMode.HALF_UP);
}
/** Alias für getNettoGesamt() GUI-Kompatibilität. */
public BigDecimal getSumme() { return getNettoGesamt(); }
}

View File

@ -0,0 +1,26 @@
package de.hsmannheim.faktura.dokumente.domain;
/**
* Steuert erlaubte Folgeaktionen im Dokumentenprozess.
* Workflow: ANGEBOT: OFFEN UEBERFUEHRT | AB: BESTAETIGT | LS: GELIEFERT | R: FESTGESCHRIEBEN
*/
public enum DokumentStatus {
OFFEN,
UEBERFUEHRT,
BESTAETIGT,
GELIEFERT,
FAKTURIERT,
FESTGESCHRIEBEN;
/** Lesbarer Anzeigename für die GUI (PHGUI10). */
public String getAnzeigename() {
return switch (this) {
case OFFEN -> "Offen";
case UEBERFUEHRT -> "Überführt";
case BESTAETIGT -> "Bestätigt";
case GELIEFERT -> "Geliefert";
case FAKTURIERT -> "Fakturiert";
case FESTGESCHRIEBEN -> "Festgeschrieben";
};
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -0,0 +1,93 @@
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);
/**
* Gibt alle gespeicherten Dokumente zurück (für die Listenansicht in der GUI).
*
* @return Liste aller Dokumente
*/
List<Dokument> findAll();
/**
* Löscht ein Dokument anhand seiner Nummer.
* Festgeschriebene Rechnungen können nicht gelöscht werden.
*
* @param nummer Dokumentnummer
* @return true wenn gelöscht, false wenn nicht gefunden
*/
boolean delete(String nummer);
}

View File

@ -0,0 +1,107 @@
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();
}
@Override
public List<Dokument> findAll() {
return new ArrayList<>(store.values());
}
@Override
public boolean delete(String nummer) {
return store.remove(nummer) != null;
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -0,0 +1,325 @@
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);
// Duplikat-Check zuerst (PH-DP-05 / MT-DP-06): wenn bereits eine AB existiert,
// ist das Angebot zwangsläufig UEBERFUEHRT und beide Prüfungen würden anschlagen.
// Die Spec verlangt in diesem Szenario explizit DuplicateDocumentException.
if (repository.existsFolgedokument(angebotsNummer)) {
throw new DuplicateDocumentException(angebotsNummer);
}
// Allgemeiner Status-Check (PH-DP-04): greift bei anderen Nicht-OFFEN-Zuständen.
if (angebot.getStatus() != DokumentStatus.OFFEN) {
throw new IllegalStateException(
"Angebot " + angebotsNummer + " ist nicht mehr OFFEN (Status: " +
angebot.getStatus() + "). Auftragsbestätigung kann nicht erzeugt werden.");
}
// 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;
}
}

View File

@ -0,0 +1,153 @@
package de.hsmannheim.faktura.dokumente.service;
import de.hsmannheim.faktura.dokumente.domain.*;
import de.hsmannheim.faktura.dokumente.export.SimplePdfExportService;
import de.hsmannheim.faktura.dokumente.lookup.*;
import de.hsmannheim.faktura.dokumente.repository.DokumentRepository;
import de.hsmannheim.faktura.dokumente.repository.InMemoryDokumentRepository;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* Fassade für die GUI (DokumentPanel).
* Kapselt den DokumentServiceImpl und bietet die Methoden die die GUI braucht.
*
* Wird in der GUI so instanziiert:
* new DokumentenService(new InMemoryDokumentRepository())
*
* Sobald Gruppe G (Produkte) und H (Kunden) ihre Lookups liefern,
* können diese über den Setter-Konstruktor injiziert werden.
*/
public class DokumentenService {
private final DokumentService service;
private final DokumentRepository repository;
/**
* Konstruktor für die GUI nimmt ein Repository entgegen.
* Produkt- und Kundenlookup werden als Stubs vorbelegt.
*/
public DokumentenService(DokumentRepository repository) {
this.repository = repository;
// Stub-Lookups bis Gruppe G und H ihre Implementierungen liefern
ProductLookup produktLookup = new StubProductLookup();
CustomerLookup kundeLookup = new StubCustomerLookup();
this.service = new DokumentServiceImpl(
repository,
produktLookup,
kundeLookup,
new SimplePdfExportService()
);
}
/**
* Vollständiger Konstruktor für Produktion wenn echte Lookups vorliegen.
*/
public DokumentenService(DokumentRepository repository,
ProductLookup produktLookup,
CustomerLookup kundeLookup) {
this.repository = repository;
this.service = new DokumentServiceImpl(
repository, produktLookup, kundeLookup, new SimplePdfExportService()
);
}
// -------------------------------------------------------------------------
// Methoden für die GUI
// -------------------------------------------------------------------------
/** Alle Dokumente für die Listenansicht. */
public List<Dokument> getAllDokumente() {
return repository.findAll();
}
/** Einzelnes Dokument nach Nummer laden. */
public Optional<Dokument> getDokumentByNummer(String nummer) {
return repository.findByNummer(nummer);
}
/**
* Neues Angebot anlegen (PH-DP-01).
* @param kundenId Kunde (muss in Kundenverwaltung existieren / vom CustomerLookup auflösbar sein)
* @param positionen mindestens eine Position (Produkt-ID + Menge)
* @return das gespeicherte Angebot mit Nummer und Status OFFEN
*/
public Angebot createAngebot(long kundenId, List<PositionCommand> positionen) {
return service.erstelleAngebot(kundenId, positionen);
}
/**
* Dokument löschen. Festgeschriebene Rechnungen können nicht gelöscht werden.
* @return true wenn erfolgreich gelöscht
*/
public boolean deleteDokument(String nummer) {
Optional<Dokument> dok = repository.findByNummer(nummer);
if (dok.isEmpty()) return false;
if (dok.get().isFestgeschrieben()) return false; // PHGUI11
return repository.delete(nummer);
}
/** Auftragsbestätigung aus Angebot erzeugen. */
public Auftragsbestaetigung createAuftragsbestaetigung(String angebotsNummer) {
return service.erstelleAuftragsbestaetigung(angebotsNummer);
}
/** Lieferschein aus AB erzeugen (Lieferdatum = heute). */
public Lieferschein createLieferschein(String auftragsNummer) {
return service.erstelleLieferschein(auftragsNummer, LocalDate.now());
}
/** Rechnung aus Lieferschein erzeugen. */
public Rechnung createRechnung(String lieferscheinNummer) {
return service.erstelleRechnung(lieferscheinNummer);
}
/** PDF-Export eines Dokuments. */
public Path exportPdf(String dokumentNummer, DokumentType typ) {
return service.exportiereAlsPdf(dokumentNummer);
}
// -------------------------------------------------------------------------
// Stub-Implementierungen (Platzhalter bis Gruppe G und H fertig sind)
// -------------------------------------------------------------------------
/**
* Platzhalter-Produktlookup.
* Ersetzen durch echte Implementierung von Gruppe G.
*/
private static class StubProductLookup implements ProductLookup {
@Override
public Optional<ProductDto> findProduct(long produktId) {
// Beispieldaten bis Gruppe G ihre Implementierung liefert
return Optional.of(new ProductDto(
produktId,
"Produkt #" + produktId,
new BigDecimal("100.00"),
new BigDecimal("0.19")
));
}
}
/**
* Platzhalter-Kundenlookup.
* Ersetzen durch echte Implementierung von Gruppe H.
*/
private static class StubCustomerLookup implements CustomerLookup {
@Override
public Optional<CustomerDto> findCustomer(long kundenId) {
// Beispieldaten bis Gruppe H ihre Implementierung liefert
return Optional.of(new CustomerDto(
kundenId,
"Kunde #" + kundenId,
"Musterstraße 1, 68163 Mannheim",
"DE123456789"
));
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,560 @@
package de.hsmannheim.faktura.gui;
import javax.swing.*;
import javax.swing.table.*;
import java.awt.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import de.hsmannheim.faktura.dokumente.domain.Angebot;
import de.hsmannheim.faktura.dokumente.domain.Auftragsbestaetigung;
import de.hsmannheim.faktura.dokumente.domain.Dokument;
import de.hsmannheim.faktura.dokumente.domain.DokumentStatus;
import de.hsmannheim.faktura.dokumente.domain.Lieferschein;
import de.hsmannheim.faktura.dokumente.service.DokumentenService;
import de.hsmannheim.faktura.dokumente.service.PositionCommand;
import de.hsmannheim.faktura.dokumente.repository.InMemoryDokumentRepository;
/**
* Panel für den Dokumentenprozess (PHGUI10, PHGUI11, PHGUI12).
* Zeigt Status und zulässige Folgeaktionen an. Deaktiviert unzulässige Aktionen (PHGUI10).
* Festgeschriebene Rechnungen sind schreibgeschützt (PHGUI11).
*/
public class DokumentPanel extends JPanel {
private final MainFrame mainFrame;
private final DokumentenService dokumentenService;
// Tabelle
private DefaultTableModel tableModel;
private JTable dokumentTable;
// Detailfelder (schreibgeschützt)
private JTextField txtDokumentnummer;
private JTextField txtKundenId;
private JTextField txtDatum;
private JTextField txtStatus;
private JCheckBox chkFestgeschrieben;
// Positionstabelle
private DefaultTableModel posTableModel;
private JTable posTable;
// Summenfelder
private JLabel lblNetto;
private JLabel lblUst;
private JLabel lblBrutto;
// Aktions-Buttons (PHGUI10)
private JButton btnNeu;
private JButton btnSpeichern;
private JButton btnLoeschen;
private JButton btnAbbrechen;
private JButton btnPdfExport;
// Folgeaktions-Buttons
private JButton btnAngebotZuAuftrag;
private JButton btnAuftragZuLieferschein;
private JButton btnLieferscheinZuRechnung;
private Dokument aktuellesDokument = null;
public DokumentPanel(MainFrame mainFrame) {
this.mainFrame = mainFrame;
// FIX: InMemoryDokumentRepository statt DokumentRepository (Interface)
this.dokumentenService = new DokumentenService(new InMemoryDokumentRepository());
setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
initComponents();
initialisiereDemoDaten();
ladeDokumente();
}
/**
* Legt beim Start einige Beispieldokumente an, damit der gesamte
* Belegfluss (Angebot AB Lieferschein Rechnung) sofort
* demonstriert werden kann.
*/
private void initialisiereDemoDaten() {
try {
// 1) Frisches Angebot (Status OFFEN) Folgeaktion "→ AB" wird aktiv
dokumentenService.createAngebot(1001L, List.of(
new PositionCommand(1L, 5),
new PositionCommand(2L, 3)));
// 2) Angebot überführt in AB (Status BESTAETIGT) Folgeaktion "→ Lieferschein" wird aktiv
Angebot a2 = dokumentenService.createAngebot(1002L,
List.of(new PositionCommand(1L, 10)));
dokumentenService.createAuftragsbestaetigung(a2.getDokumentNummer());
// 3) Kette bis Lieferschein (Status GELIEFERT) Folgeaktion "→ Rechnung" wird aktiv
Angebot a3 = dokumentenService.createAngebot(1003L,
List.of(new PositionCommand(3L, 2)));
Auftragsbestaetigung ab3 =
dokumentenService.createAuftragsbestaetigung(a3.getDokumentNummer());
dokumentenService.createLieferschein(ab3.getDokumentNummer());
// 4) Komplette Kette bis Rechnung (Status FESTGESCHRIEBEN) nur PDF-Export aktiv
Angebot a4 = dokumentenService.createAngebot(1004L,
List.of(new PositionCommand(1L, 1)));
Auftragsbestaetigung ab4 =
dokumentenService.createAuftragsbestaetigung(a4.getDokumentNummer());
Lieferschein ls4 =
dokumentenService.createLieferschein(ab4.getDokumentNummer());
dokumentenService.createRechnung(ls4.getDokumentNummer());
} catch (Exception ex) {
System.err.println("Demo-Daten konnten nicht initialisiert werden: "
+ ex.getMessage());
}
}
private void initComponents() {
// --- Linke Seite: Dokumentenliste ---
JPanel leftPanel = new JPanel(new BorderLayout(4, 4));
leftPanel.setPreferredSize(new Dimension(300, 0));
String[] cols = {"Nr.", "Typ", "Status"};
tableModel = new DefaultTableModel(cols, 0) {
public boolean isCellEditable(int r, int c) { return false; }
};
dokumentTable = new JTable(tableModel) {
// Status-Spalte farbig (PHGUI10)
public Component prepareRenderer(TableCellRenderer r, int row, int col) {
Component c = super.prepareRenderer(r, row, col);
if (col == 2) {
String status = (String) getValueAt(row, col);
if (status != null) {
if (status.contains("Offen")) c.setForeground(new Color(41, 128, 185));
else if (status.contains("Bestätigt")) c.setForeground(new Color(39, 174, 96));
else if (status.contains("Festgeschr")) c.setForeground(new Color(192, 57, 43));
else c.setForeground(Color.BLACK);
}
} else {
c.setForeground(Color.BLACK);
}
return c;
}
};
dokumentTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
dokumentTable.setAutoCreateRowSorter(true);
dokumentTable.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) zeigeAusgewaehltes();
});
leftPanel.add(new JLabel("Dokumente"), BorderLayout.NORTH);
leftPanel.add(new JScrollPane(dokumentTable), BorderLayout.CENTER);
// --- Rechte Seite: Detailansicht ---
JPanel rightPanel = new JPanel(new BorderLayout(4, 8));
JPanel headerPanel = new JPanel(new GridBagLayout());
headerPanel.setBorder(BorderFactory.createTitledBorder("Dokumentdetail"));
GridBagConstraints g = new GridBagConstraints();
g.insets = new Insets(3, 4, 3, 4);
g.fill = GridBagConstraints.HORIZONTAL;
g.weightx = 0.5;
// Festgeschrieben-Checkbox (PHGUI11)
chkFestgeschrieben = new JCheckBox("Festgeschrieben");
chkFestgeschrieben.setEnabled(false);
g.gridx = 1; g.gridy = 0; g.anchor = GridBagConstraints.EAST;
headerPanel.add(chkFestgeschrieben, g);
g.anchor = GridBagConstraints.WEST;
g.gridx = 0; g.gridy = 1; headerPanel.add(new JLabel("Dokumentnummer"), g);
g.gridx = 1; headerPanel.add(new JLabel("Kundennummer"), g);
txtDokumentnummer = createReadOnlyField();
txtKundenId = createReadOnlyField();
g.gridx = 0; g.gridy = 2; headerPanel.add(txtDokumentnummer, g);
g.gridx = 1; headerPanel.add(txtKundenId, g);
g.gridx = 0; g.gridy = 3; headerPanel.add(new JLabel("Datum"), g);
g.gridx = 1; headerPanel.add(new JLabel("Status"), g);
txtDatum = createReadOnlyField();
txtStatus = createReadOnlyField();
g.gridx = 0; g.gridy = 4; headerPanel.add(txtDatum, g);
g.gridx = 1; headerPanel.add(txtStatus, g);
rightPanel.add(headerPanel, BorderLayout.NORTH);
// Positionstabelle
String[] posCols = {"Pos", "Bezeichnung", "Menge", "Einzelpreis", "MwSt.", "Summe"};
posTableModel = new DefaultTableModel(posCols, 0) {
public boolean isCellEditable(int r, int c) { return false; }
};
posTable = new JTable(posTableModel);
posTable.setEnabled(false);
JPanel posPanel = new JPanel(new BorderLayout());
posPanel.setBorder(BorderFactory.createTitledBorder("Positionen:"));
posPanel.add(new JScrollPane(posTable), BorderLayout.CENTER);
// Summen
JPanel summenPanel = new JPanel(new GridLayout(3, 2, 4, 2));
summenPanel.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8));
lblNetto = new JLabel("0,00 €"); lblNetto.setHorizontalAlignment(SwingConstants.RIGHT);
lblUst = new JLabel("0,00 €"); lblUst.setHorizontalAlignment(SwingConstants.RIGHT);
lblBrutto = new JLabel("0,00 €", SwingConstants.RIGHT);
lblBrutto.setFont(new Font("SansSerif", Font.BOLD, 13));
summenPanel.add(new JLabel("Netto:")); summenPanel.add(lblNetto);
summenPanel.add(new JLabel("USt.:")); summenPanel.add(lblUst);
summenPanel.add(new JLabel("Brutto:")); summenPanel.add(lblBrutto);
JPanel mittelPanel = new JPanel(new BorderLayout());
mittelPanel.add(posPanel, BorderLayout.CENTER);
mittelPanel.add(summenPanel, BorderLayout.SOUTH);
rightPanel.add(mittelPanel, BorderLayout.CENTER);
// --- Aktionen (PHGUI10: nur zulässige aktiv) ---
JPanel aktionenPanel = new JPanel(new BorderLayout());
aktionenPanel.setBorder(BorderFactory.createTitledBorder("Aktionen:"));
JPanel stdButtons = new JPanel(new FlowLayout(FlowLayout.LEFT));
btnNeu = new JButton("Neu");
btnSpeichern = new JButton("Speichern");
btnLoeschen = new JButton("Löschen");
btnAbbrechen = new JButton("Abbrechen");
btnPdfExport = new JButton("PDF-Export");
btnPdfExport.setBackground(new Color(41, 128, 185));
btnPdfExport.setForeground(Color.WHITE);
stdButtons.add(btnNeu);
stdButtons.add(btnSpeichern);
stdButtons.add(btnLoeschen);
stdButtons.add(btnAbbrechen);
stdButtons.add(btnPdfExport);
JPanel folgeButtons = new JPanel(new FlowLayout(FlowLayout.LEFT));
btnAngebotZuAuftrag = new JButton("→ Auftragsbestätigung erstellen");
btnAuftragZuLieferschein = new JButton("→ Lieferschein erstellen");
btnLieferscheinZuRechnung = new JButton("→ Rechnung erstellen");
folgeButtons.add(btnAngebotZuAuftrag);
folgeButtons.add(btnAuftragZuLieferschein);
folgeButtons.add(btnLieferscheinZuRechnung);
aktionenPanel.add(stdButtons, BorderLayout.NORTH);
aktionenPanel.add(folgeButtons, BorderLayout.SOUTH);
rightPanel.add(aktionenPanel, BorderLayout.SOUTH);
// Button-Actions
btnNeu.addActionListener(e -> neuesAngebotDialog());
btnSpeichern.addActionListener(e -> neuesAngebotDialog());
btnAbbrechen.addActionListener(e -> { neuesFormular(); ladeDokumente(); });
btnPdfExport.addActionListener(e -> pdfExport());
btnLoeschen.addActionListener(e -> loeschen());
btnAngebotZuAuftrag.addActionListener(e -> folgeaktion("ANGEBOT_ZU_AUFTRAG"));
btnAuftragZuLieferschein.addActionListener(e -> folgeaktion("AUFTRAG_ZU_LIEFERSCHEIN"));
btnLieferscheinZuRechnung.addActionListener(e -> folgeaktion("LIEFERSCHEIN_ZU_RECHNUNG"));
setAktionenDeaktiviert();
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftPanel, rightPanel);
split.setDividerLocation(300);
add(split, BorderLayout.CENTER);
}
// --- Daten ---
private void ladeDokumente() {
tableModel.setRowCount(0);
List<Dokument> alle = dokumentenService.getAllDokumente();
for (Dokument d : alle) {
tableModel.addRow(new Object[]{
d.getDokumentNummer(), // FIX: getDokumentNummer() statt getDokumentnummer()
d.getTypBezeichnung(),
d.getStatus().getAnzeigename()
});
}
}
private void zeigeAusgewaehltes() {
int row = dokumentTable.getSelectedRow();
if (row < 0) return;
int modelRow = dokumentTable.convertRowIndexToModel(row);
String nr = (String) tableModel.getValueAt(modelRow, 0);
dokumentenService.getDokumentByNummer(nr).ifPresent(d -> {
aktuellesDokument = d;
txtDokumentnummer.setText(d.getDokumentNummer()); // FIX
txtKundenId.setText(String.valueOf(d.getKundenId())); // FIX
txtDatum.setText(d.getDatum().toString());
txtStatus.setText(d.getStatus().getAnzeigename());
chkFestgeschrieben.setSelected(d.isFestgeschrieben());
// Positionen laden
posTableModel.setRowCount(0);
int pos = 1;
for (var position : d.getPositionen()) {
posTableModel.addRow(new Object[]{
pos++,
position.getBezeichnungSnapshot(), // FIX
position.getMenge(),
position.getEinzelpreisNettoSnapshot() + " €", // FIX
position.getMwstSatzSnapshot().multiply(java.math.BigDecimal.valueOf(100))
.setScale(0, java.math.RoundingMode.HALF_UP) + "%",// FIX
position.getNettoGesamt() + " €" // FIX
});
}
// Summen (nicht für Lieferschein)
if (d.getNettoSumme() != null) { // FIX
lblNetto.setText(d.getNettoSumme().toPlainString() + " €"); // FIX
lblUst.setText(d.getUstSumme().toPlainString() + " €");
lblBrutto.setText(d.getBruttoSumme().toPlainString() + " €"); // FIX
}
aktualisiereAktionen(d);
});
}
/**
* Aktiviert nur zulässige Folgeaktionen basierend auf Dokumentstatus (PHGUI10).
* Festgeschriebene Rechnungen: nur PDF-Export aktiv (PHGUI11).
*/
private void aktualisiereAktionen(Dokument d) {
DokumentStatus status = d.getStatus();
boolean festgeschrieben = d.isFestgeschrieben();
// PHGUI11: Festgeschriebene Rechnung → nur PDF-Export
if (festgeschrieben) {
btnNeu.setEnabled(true);
btnSpeichern.setEnabled(false);
btnLoeschen.setEnabled(false);
btnAbbrechen.setEnabled(true);
btnPdfExport.setEnabled(true); // PHGUI12
btnAngebotZuAuftrag.setEnabled(false);
btnAuftragZuLieferschein.setEnabled(false);
btnLieferscheinZuRechnung.setEnabled(false);
return;
}
// PHGUI10: Folgeaktionen je nach Status (FIX: korrekte Status-Konstanten)
btnNeu.setEnabled(true);
btnSpeichern.setEnabled(true);
btnLoeschen.setEnabled(true);
btnAbbrechen.setEnabled(true);
btnPdfExport.setEnabled(status == DokumentStatus.FESTGESCHRIEBEN); // FIX
btnAngebotZuAuftrag.setEnabled(status == DokumentStatus.OFFEN); // FIX
btnAuftragZuLieferschein.setEnabled(status == DokumentStatus.BESTAETIGT); // FIX
btnLieferscheinZuRechnung.setEnabled(status == DokumentStatus.GELIEFERT); // FIX
}
private void setAktionenDeaktiviert() {
btnSpeichern.setEnabled(false);
btnLoeschen.setEnabled(false);
btnPdfExport.setEnabled(false);
btnAngebotZuAuftrag.setEnabled(false);
btnAuftragZuLieferschein.setEnabled(false);
btnLieferscheinZuRechnung.setEnabled(false);
}
private void neuesFormular() {
aktuellesDokument = null;
txtDokumentnummer.setText(""); txtKundenId.setText("");
txtDatum.setText(""); txtStatus.setText("");
chkFestgeschrieben.setSelected(false);
posTableModel.setRowCount(0);
lblNetto.setText("0,00 €"); lblUst.setText("0,00 €"); lblBrutto.setText("0,00 €");
setAktionenDeaktiviert();
btnNeu.setEnabled(true); btnAbbrechen.setEnabled(true);
dokumentTable.clearSelection();
}
private void loeschen() {
if (aktuellesDokument == null) {
mainFrame.showStatus("Kein Dokument ausgewählt.");
return;
}
int confirm = JOptionPane.showConfirmDialog(
this, "Dokument wirklich löschen?", "Löschen bestätigen",
JOptionPane.YES_NO_OPTION
);
if (confirm != JOptionPane.YES_OPTION) return;
boolean deleted = dokumentenService.deleteDokument(aktuellesDokument.getDokumentNummer()); // FIX
if (deleted) {
ladeDokumente(); neuesFormular();
mainFrame.showStatus("Dokument gelöscht.");
} else {
mainFrame.showStatus("Dokument kann nicht gelöscht werden (z. B. festgeschriebene Rechnung).");
}
}
private void folgeaktion(String typ) {
if (aktuellesDokument == null) return;
try {
switch (typ) {
case "ANGEBOT_ZU_AUFTRAG" ->
dokumentenService.createAuftragsbestaetigung(aktuellesDokument.getDokumentNummer()); // FIX
case "AUFTRAG_ZU_LIEFERSCHEIN" ->
dokumentenService.createLieferschein(aktuellesDokument.getDokumentNummer()); // FIX
case "LIEFERSCHEIN_ZU_RECHNUNG" ->
dokumentenService.createRechnung(aktuellesDokument.getDokumentNummer()); // FIX
}
ladeDokumente();
mainFrame.showStatus("Folgedokument erfolgreich erstellt.");
} catch (Exception ex) {
mainFrame.showStatus("Fehler: " + ex.getMessage());
}
}
private void pdfExport() {
if (aktuellesDokument == null) {
mainFrame.showStatus("Kein Dokument ausgewählt.");
return;
}
try {
dokumentenService.exportPdf(
aktuellesDokument.getDokumentNummer(), aktuellesDokument.getTyp()); // FIX
mainFrame.showStatus("PDF-Export erfolgreich.");
} catch (Exception ex) {
mainFrame.showStatus("PDF-Export fehlgeschlagen: " + ex.getMessage());
}
}
private JTextField createReadOnlyField() {
JTextField f = new JTextField();
f.setEditable(false);
f.setBackground(new Color(230, 230, 230));
return f;
}
// -------------------------------------------------------------------------
// Dialog: Neues Angebot anlegen
// -------------------------------------------------------------------------
/**
* Öffnet einen modalen Dialog zum Anlegen eines neuen Angebots.
* Eingaben: Kunden-ID und mindestens eine Position (Produkt-ID + Menge).
* Bei erfolgreichem Anlegen wird das Angebot gespeichert und die
* Dokumentenliste neu geladen.
*/
private void neuesAngebotDialog() {
JDialog dialog = new JDialog(
(Frame) SwingUtilities.getWindowAncestor(this),
"Neues Angebot anlegen", true);
dialog.setLayout(new BorderLayout(8, 8));
((JComponent) dialog.getContentPane())
.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
// --- Kopfbereich: Kunden-ID ---
JPanel headPanel = new JPanel(new GridBagLayout());
headPanel.setBorder(BorderFactory.createTitledBorder("Kunde"));
GridBagConstraints g = new GridBagConstraints();
g.insets = new Insets(3, 4, 3, 4);
g.fill = GridBagConstraints.HORIZONTAL;
g.weightx = 1.0;
g.gridx = 0; g.gridy = 0; headPanel.add(new JLabel("Kunden-ID:"), g);
JTextField txtKundenIdInput = new JTextField("1001");
g.gridx = 1; headPanel.add(txtKundenIdInput, g);
// --- Positionsbereich ---
String[] cols = {"Produkt-ID", "Menge"};
DefaultTableModel posModel = new DefaultTableModel(cols, 0) {
@Override
public Class<?> getColumnClass(int column) { return String.class; }
@Override
public boolean isCellEditable(int r, int c) { return true; }
};
JTable tblPositionen = new JTable(posModel);
tblPositionen.setRowHeight(22);
// Eine Beispielzeile zum Loslegen
posModel.addRow(new Object[]{"1", "1"});
JPanel posButtons = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton btnPosHinzu = new JButton("Position hinzufügen");
JButton btnPosEntfernen = new JButton("Position entfernen");
posButtons.add(btnPosHinzu);
posButtons.add(btnPosEntfernen);
btnPosHinzu.addActionListener(ev -> posModel.addRow(new Object[]{"1", "1"}));
btnPosEntfernen.addActionListener(ev -> {
int row = tblPositionen.getSelectedRow();
if (row >= 0) posModel.removeRow(row);
});
JPanel posPanel = new JPanel(new BorderLayout(4, 4));
posPanel.setBorder(BorderFactory.createTitledBorder("Positionen"));
posPanel.add(new JScrollPane(tblPositionen), BorderLayout.CENTER);
posPanel.add(posButtons, BorderLayout.SOUTH);
// --- OK / Abbrechen ---
JButton btnOk = new JButton("Anlegen");
JButton btnCancel = new JButton("Abbrechen");
JPanel dlgButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT));
dlgButtons.add(btnOk);
dlgButtons.add(btnCancel);
JLabel lblFehler = new JLabel(" ");
lblFehler.setForeground(new Color(192, 57, 43));
btnCancel.addActionListener(ev -> dialog.dispose());
btnOk.addActionListener(ev -> {
// Bei in der Tabelle laufender Bearbeitung: Wert übernehmen
if (tblPositionen.isEditing()) tblPositionen.getCellEditor().stopCellEditing();
// Kunden-ID parsen
long kundenId;
try {
kundenId = Long.parseLong(txtKundenIdInput.getText().trim());
} catch (NumberFormatException ex) {
lblFehler.setText("Kunden-ID muss eine Zahl sein.");
return;
}
// Positionen aufbauen
List<PositionCommand> positionen = new ArrayList<>();
for (int r = 0; r < posModel.getRowCount(); r++) {
Object pidObj = posModel.getValueAt(r, 0);
Object mengeObj = posModel.getValueAt(r, 1);
if (pidObj == null || mengeObj == null
|| pidObj.toString().isBlank() || mengeObj.toString().isBlank()) {
continue;
}
try {
long pid = Long.parseLong(pidObj.toString().trim());
int menge = Integer.parseInt(mengeObj.toString().trim());
positionen.add(new PositionCommand(pid, menge));
} catch (NumberFormatException ex) {
lblFehler.setText("Position " + (r + 1)
+ ": Produkt-ID und Menge müssen Zahlen sein.");
return;
} catch (IllegalArgumentException ex) {
lblFehler.setText("Position " + (r + 1) + ": " + ex.getMessage());
return;
}
}
if (positionen.isEmpty()) {
lblFehler.setText("Mindestens eine Position erforderlich.");
return;
}
// Angebot anlegen
try {
Angebot a = dokumentenService.createAngebot(kundenId, positionen);
ladeDokumente();
mainFrame.showStatus("Angebot " + a.getDokumentNummer()
+ " angelegt (Netto " + a.getNettoSumme() + " €).");
dialog.dispose();
} catch (Exception ex) {
lblFehler.setText("Fehler: " + ex.getMessage());
}
});
JPanel southPanel = new JPanel(new BorderLayout());
southPanel.add(lblFehler, BorderLayout.NORTH);
southPanel.add(dlgButtons, BorderLayout.SOUTH);
dialog.add(headPanel, BorderLayout.NORTH);
dialog.add(posPanel, BorderLayout.CENTER);
dialog.add(southPanel, BorderLayout.SOUTH);
dialog.setSize(480, 360);
dialog.setLocationRelativeTo(this);
dialog.setVisible(true);
}
}

View File

@ -0,0 +1,420 @@
package de.hsmannheim.faktura.gui;
import javax.swing.*;
import javax.swing.table.*;
import java.awt.*;
import java.io.File;
import java.util.List;
import de.hsmannheim.faktura.kunde.model.Kunde;
import de.hsmannheim.faktura.kunde.repository.KundeRepository;
import de.hsmannheim.faktura.kunde.repository.KundeRepositoryJson;
import de.hsmannheim.faktura.kunde.service.BelegReferenzPruefer;
import de.hsmannheim.faktura.kunde.service.KundeService;
import de.hsmannheim.faktura.kunde.service.KundeValidator;
import de.hsmannheim.faktura.kunde.service.LoeschsperreException;
import de.hsmannheim.faktura.kunde.service.ValidierungsException;
/**
* Panel für die Kundenverwaltung (PHGUI02, PHGUI03, PHGUI05PHGUI09).
*
* Verdrahtung gegen die spec-konforme Schnittstelle K1 aus dem
* gemeinsamen Pflichtenheft v1.1 (Kap. 6.1.2 / Kap. 7.2):
* - Klassennamen: {@link KundeService}, {@link KundeRepository}, {@link Kunde}
* - Methoden: anlegen / bearbeiten / suchen / loeschen / finde / alle
* - Typ kundeId: long
*
* Das ursprüngliche GUI-Modell (KundenService / KundenRepository /
* 8-arg-Konstruktor / getKundenId():int) ist eine Erfindung des
* Gruppen-E-Pflichtenhefts und wird hier auf die gemeinsame Spec
* gemappt. Eintrag im Fahrplan: OP-6 (GUI vs. PH v1.1).
*/
public class KundenPanel extends JPanel {
private final MainFrame mainFrame;
private final KundeService kundeService;
// Tabelle
private DefaultTableModel tableModel;
private JTable kundenTable;
// Formularfelder
private JTextField txtKundeId;
private JTextField txtFirmenname;
private JTextField txtNachname;
private JTextField txtVorname;
private JTextField txtStrasse;
private JTextField txtPlz;
private JTextField txtOrt;
private JTextField txtTelefon;
private JTextField txtEmail;
private JTextField txtUstIdNr;
// Fehlerhinweise (PHGUI03, PHGUI05)
private JLabel lblNameFehler;
private JLabel lblStrasseFehler;
private JLabel lblPlzFehler;
private JLabel lblOrtFehler;
private JLabel lblEmailFehler;
// Suche (PHGUI09)
private JTextField txtSuche;
// Buttons (PHGUI02)
private JButton btnNeu;
private JButton btnSpeichern;
private JButton btnLoeschen;
private JButton btnAbbrechen;
public KundenPanel(MainFrame mainFrame) {
this.mainFrame = mainFrame;
this.kundeService = new KundeService(
new KundeRepositoryJson(new File("data/kunden.json")),
new KundeValidator(),
stubBelegPruefer()
);
setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
initComponents();
ladeKunden();
}
/**
* GR-05-Löschsperre lokal deaktiviert: das DokumentPanel hat eine
* eigene Repository-Instanz, eine geteilte Belegsicht erfordert ein
* zentrales Wiring (über MainFrame) und ist Bestandteil von OP-6.
*/
private BelegReferenzPruefer stubBelegPruefer() {
return kundeId -> false;
}
private void initComponents() {
// --- Linke Seite: Liste + Suche (PHGUI09) ---
JPanel leftPanel = new JPanel(new BorderLayout(4, 4));
leftPanel.setPreferredSize(new Dimension(320, 0));
String[] columns = {"ID", "Name", "Ort"};
tableModel = new DefaultTableModel(columns, 0) {
public boolean isCellEditable(int r, int c) { return false; }
};
kundenTable = new JTable(tableModel);
kundenTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
kundenTable.getTableHeader().setReorderingAllowed(false);
kundenTable.setAutoCreateRowSorter(true); // PHGUI08
kundenTable.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) zeigeAusgewaehlten();
});
JPanel suchPanel = new JPanel(new BorderLayout(4, 0));
suchPanel.add(new JLabel("Suche: "), BorderLayout.WEST);
txtSuche = new JTextField();
txtSuche.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() {
public void insertUpdate(javax.swing.event.DocumentEvent e) { sucheAktualisieren(); }
public void removeUpdate(javax.swing.event.DocumentEvent e) { sucheAktualisieren(); }
public void changedUpdate(javax.swing.event.DocumentEvent e) { sucheAktualisieren(); }
});
suchPanel.add(txtSuche, BorderLayout.CENTER);
leftPanel.add(suchPanel, BorderLayout.NORTH);
leftPanel.add(new JScrollPane(kundenTable), BorderLayout.CENTER);
// --- Rechte Seite: Formular + Buttons (PHGUI02) ---
JPanel rightPanel = new JPanel(new BorderLayout(0, 8));
rightPanel.add(formularPanel(), BorderLayout.CENTER);
rightPanel.add(buttonPanel(), BorderLayout.SOUTH);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftPanel, rightPanel);
split.setDividerLocation(320);
add(split, BorderLayout.CENTER);
}
private JPanel formularPanel() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createTitledBorder("Kundendaten"));
GridBagConstraints g = new GridBagConstraints();
g.insets = new Insets(4, 6, 2, 6);
g.anchor = GridBagConstraints.WEST;
g.fill = GridBagConstraints.HORIZONTAL;
g.weightx = 1.0;
txtKundeId = createReadOnlyField();
txtFirmenname = new JTextField();
txtNachname = new JTextField();
txtVorname = new JTextField();
txtStrasse = new JTextField();
txtPlz = new JTextField();
txtOrt = new JTextField();
txtTelefon = new JTextField();
txtEmail = new JTextField();
txtUstIdNr = new JTextField();
lblNameFehler = createFehlerLabel();
lblStrasseFehler = createFehlerLabel();
lblPlzFehler = createFehlerLabel();
lblOrtFehler = createFehlerLabel();
lblEmailFehler = createFehlerLabel();
lblEmailFehler.setText("ⓘ Format: name@domain.tld");
lblEmailFehler.setForeground(new Color(120, 120, 120));
int row = 0;
row = addLabelField(p, g, "Kunden-ID:", txtKundeId, null, row);
row = addLabelField(p, g, "Firmenname *:", txtFirmenname, lblNameFehler, row);
row = addLabelField(p, g, "Nachname *:", txtNachname, null, row);
row = addLabelField(p, g, "Vorname:", txtVorname, null, row);
row = addLabelField(p, g, "Straße + Nr. *:", txtStrasse, lblStrasseFehler, row);
row = addLabelField(p, g, "PLZ *:", txtPlz, lblPlzFehler, row);
row = addLabelField(p, g, "Ort *:", txtOrt, lblOrtFehler, row);
row = addLabelField(p, g, "Telefon:", txtTelefon, null, row);
row = addLabelField(p, g, "E-Mail:", txtEmail, lblEmailFehler, row);
row = addLabelField(p, g, "USt-IdNr.:", txtUstIdNr, null, row);
// Fülldummy, damit obere Felder oben bleiben
g.gridx = 0; g.gridy = row; g.weighty = 1.0;
p.add(new JLabel(" "), g);
JLabel hint = new JLabel("* Pflichtfeld Firmenname ODER Nachname ist Pflicht (PH v1.1 Kap. 6.1.2)");
hint.setFont(new Font("SansSerif", Font.ITALIC, 11));
hint.setForeground(new Color(120, 120, 120));
JPanel wrap = new JPanel(new BorderLayout());
wrap.add(p, BorderLayout.CENTER);
wrap.add(hint, BorderLayout.SOUTH);
return wrap;
}
private int addLabelField(JPanel p, GridBagConstraints g,
String label, JComponent field, JLabel fehlerLabel, int row) {
g.weighty = 0;
g.gridx = 0; g.gridy = row; g.weightx = 0; p.add(new JLabel(label), g);
g.gridx = 1; g.gridy = row; g.weightx = 1.0; p.add(field, g);
row++;
if (fehlerLabel != null) {
g.gridx = 1; g.gridy = row; g.weightx = 1.0; p.add(fehlerLabel, g);
row++;
}
return row;
}
private JPanel buttonPanel() {
JPanel p = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 4));
btnNeu = new JButton("Neu");
btnSpeichern = new JButton("Speichern");
btnLoeschen = new JButton("Löschen");
btnAbbrechen = new JButton("Abbrechen");
btnNeu.addActionListener(e -> neuesFormular());
btnSpeichern.addActionListener(e -> speichern());
btnLoeschen.addActionListener(e -> loeschen());
btnAbbrechen.addActionListener(e -> { neuesFormular(); kundenTable.clearSelection(); });
p.add(btnNeu); p.add(btnSpeichern); p.add(btnLoeschen); p.add(btnAbbrechen);
return p;
}
// --- Daten ---
private void ladeKunden() {
ladeKundenListe(kundeService.alle());
}
private void ladeKundenListe(List<Kunde> liste) {
tableModel.setRowCount(0);
for (Kunde k : liste) {
tableModel.addRow(new Object[]{
Long.toString(k.getKundeId()),
nameVon(k),
nullAls(k.getOrt())
});
}
}
/**
* Anzeigename gemäß PH v1.1 Kap. 6.1.2: firmenname ODER nachname ist
* Pflicht. Liefert firmenname, sonst nachname[, vorname].
*/
private static String nameVon(Kunde k) {
if (k.getFirmenname() != null && !k.getFirmenname().isBlank()) {
return k.getFirmenname();
}
String n = nullAls(k.getNachname());
if (k.getVorname() != null && !k.getVorname().isBlank()) {
return n + ", " + k.getVorname();
}
return n;
}
private static String nullAls(String s) { return s == null ? "" : s; }
private void sucheAktualisieren() {
// PHGUI09: Treffer über Name UND Kundennummer.
// KundeService.suchen("") liefert bewusst eine leere Liste (P4-3),
// deshalb hier explizit auf .alle() umschalten.
String query = txtSuche.getText();
if (query == null || query.isBlank()) {
ladeKundenListe(kundeService.alle());
} else {
ladeKundenListe(kundeService.suchen(query));
}
}
private void zeigeAusgewaehlten() {
int row = kundenTable.getSelectedRow();
if (row < 0) return;
int modelRow = kundenTable.convertRowIndexToModel(row);
String idStr = (String) tableModel.getValueAt(modelRow, 0);
try {
long kundeId = Long.parseLong(idStr);
kundeService.finde(kundeId).ifPresent(this::fuelleFormular);
} catch (NumberFormatException ignored) {
// Tabelle in inkonsistentem Zustand; einfach ignorieren
}
}
private void fuelleFormular(Kunde k) {
txtKundeId.setText(Long.toString(k.getKundeId()));
txtFirmenname.setText(nullAls(k.getFirmenname()));
txtNachname.setText(nullAls(k.getNachname()));
txtVorname.setText(nullAls(k.getVorname()));
txtStrasse.setText(nullAls(k.getStrasse()));
txtPlz.setText(nullAls(k.getPlz()));
txtOrt.setText(nullAls(k.getOrt()));
txtTelefon.setText(nullAls(k.getTelefon()));
txtEmail.setText(nullAls(k.getEmail()));
txtUstIdNr.setText(nullAls(k.getUstIdNr()));
clearFehler();
}
// --- Aktionen ---
private void neuesFormular() {
txtKundeId.setText("");
txtFirmenname.setText(""); txtNachname.setText(""); txtVorname.setText("");
txtStrasse.setText(""); txtPlz.setText(""); txtOrt.setText("");
txtTelefon.setText(""); txtEmail.setText(""); txtUstIdNr.setText("");
clearFehler();
kundenTable.clearSelection();
}
private void speichern() {
clearFehler();
// GUI-seitige Vorprüfung der Pflichtfelder (PHGUI03) - die fachliche
// Validierung gegen PH v1.1 macht der KundeValidator beim Service-Aufruf.
boolean clientValid = true;
if (istLeer(txtFirmenname) && istLeer(txtNachname)) {
lblNameFehler.setText("⚠ Firmenname ODER Nachname erforderlich");
clientValid = false;
}
if (istLeer(txtStrasse)) { lblStrasseFehler.setText("⚠ Pflichtfeld"); clientValid = false; }
if (istLeer(txtPlz)) { lblPlzFehler.setText("⚠ Pflichtfeld"); clientValid = false; }
if (istLeer(txtOrt)) { lblOrtFehler.setText("⚠ Pflichtfeld"); clientValid = false; }
if (!clientValid) return;
// Kunde-Objekt aufbauen (no-arg-Konstruktor + Setter; Jackson-kompatibel)
Kunde k = new Kunde();
if (!txtKundeId.getText().isBlank()) {
try { k.setKundeId(Long.parseLong(txtKundeId.getText().trim())); }
catch (NumberFormatException ignored) { /* bleibt 0 = Neuanlage */ }
}
k.setFirmenname(leerAlsNull(txtFirmenname));
k.setNachname(leerAlsNull(txtNachname));
k.setVorname(leerAlsNull(txtVorname));
k.setStrasse(leerAlsNull(txtStrasse));
k.setPlz(leerAlsNull(txtPlz));
k.setOrt(leerAlsNull(txtOrt));
k.setTelefon(leerAlsNull(txtTelefon));
k.setEmail(leerAlsNull(txtEmail));
k.setUstIdNr(leerAlsNull(txtUstIdNr));
try {
if (k.getKundeId() == 0) {
long neueId = kundeService.anlegen(k); // F-KV-01
k.setKundeId(neueId);
} else {
kundeService.bearbeiten(k); // F-KV-02
}
ladeKunden();
mainFrame.showStatus("Kunde gespeichert (ID " + k.getKundeId() + ")."); // PHGUI06
neuesFormular();
} catch (ValidierungsException ex) {
// Fehler aus KundeValidator (PH v1.1 Kap. 6.1.2) sichtbar machen
String msg = ex.getMessage();
if (msg.toLowerCase().contains("email") || msg.toLowerCase().contains("e-mail")) {
lblEmailFehler.setText("⚠ " + msg);
lblEmailFehler.setForeground(new Color(192, 57, 43));
} else if (msg.toLowerCase().contains("firmen") || msg.toLowerCase().contains("nach")) {
lblNameFehler.setText("⚠ " + msg);
} else if (msg.toLowerCase().contains("plz")) {
lblPlzFehler.setText("⚠ " + msg);
} else if (msg.toLowerCase().contains("straße") || msg.toLowerCase().contains("strasse")) {
lblStrasseFehler.setText("⚠ " + msg);
} else if (msg.toLowerCase().contains("ort")) {
lblOrtFehler.setText("⚠ " + msg);
} else {
mainFrame.showStatus("Validierung: " + msg);
}
}
}
private void loeschen() {
int row = kundenTable.getSelectedRow();
if (row < 0) { mainFrame.showStatus("Kein Kunde ausgewählt."); return; }
// PHGUI07: Bestätigungsdialog
int confirm = JOptionPane.showConfirmDialog(
this, "Kunden wirklich löschen?", "Löschen bestätigen",
JOptionPane.YES_NO_OPTION);
if (confirm != JOptionPane.YES_OPTION) return;
int modelRow = kundenTable.convertRowIndexToModel(row);
String idStr = (String) tableModel.getValueAt(modelRow, 0);
try {
long kundeId = Long.parseLong(idStr);
kundeService.loeschen(kundeId); // F-KV-04
ladeKunden();
neuesFormular();
mainFrame.showStatus("Kunde gelöscht.");
} catch (LoeschsperreException ex) {
mainFrame.showStatus("Löschen nicht möglich " + ex.getMessage()); // GR-05
} catch (ValidierungsException ex) {
mainFrame.showStatus("Fehler: " + ex.getMessage());
} catch (NumberFormatException ex) {
mainFrame.showStatus("Ungültige Kunden-ID.");
}
}
// --- Hilfsmethoden ---
private boolean istLeer(JTextField f) {
return f.getText() == null || f.getText().trim().isEmpty();
}
private String leerAlsNull(JTextField f) {
String s = f.getText() == null ? null : f.getText().trim();
return (s == null || s.isEmpty()) ? null : s;
}
private void clearFehler() {
lblNameFehler.setText("");
lblStrasseFehler.setText("");
lblPlzFehler.setText("");
lblOrtFehler.setText("");
lblEmailFehler.setText("ⓘ Format: name@domain.tld");
lblEmailFehler.setForeground(new Color(120, 120, 120));
}
private JLabel createFehlerLabel() {
JLabel lbl = new JLabel("");
lbl.setForeground(new Color(192, 57, 43));
lbl.setFont(new Font("SansSerif", Font.PLAIN, 11));
return lbl;
}
private JTextField createReadOnlyField() {
JTextField f = new JTextField();
f.setEditable(false);
f.setBackground(new Color(230, 230, 230));
return f;
}
}

View File

@ -0,0 +1,104 @@
package de.hsmannheim.faktura.gui;
import javax.swing.*;
import java.awt.*;
/**
* Hauptfenster der Anwendung (PHGUI01, PHGUI02).
* Stellt die dauerhaft sichtbare Hauptnavigation bereit und lädt die Modulpanels.
*/
public class MainFrame extends JFrame {
private final ProduktPanel produktPanel;
private final KundenPanel kundenPanel;
private final DokumentPanel dokumentPanel;
private final JLabel statusLabel;
public MainFrame() {
setTitle("Fakturierungssystem");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setSize(1100, 700);
setLocationRelativeTo(null);
// Hauptlayout
setLayout(new BorderLayout());
// --- Navigation (PHGUI01) ---
JPanel navPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
navPanel.setBackground(new Color(52, 73, 94));
JLabel title = new JLabel(" Fakturierungssystem ");
title.setForeground(Color.WHITE);
title.setFont(new Font("SansSerif", Font.BOLD, 14));
navPanel.add(title);
JButton btnProdukt = createNavButton("Produktverwaltung");
JButton btnKunde = createNavButton("Kundenverwaltung");
JButton btnDokument = createNavButton("Dokumentenprozess");
navPanel.add(btnProdukt);
navPanel.add(btnKunde);
navPanel.add(btnDokument);
add(navPanel, BorderLayout.NORTH);
// --- Inhaltsbereich ---
JPanel contentArea = new JPanel(new CardLayout());
produktPanel = new ProduktPanel(this);
kundenPanel = new KundenPanel(this);
dokumentPanel = new DokumentPanel(this);
contentArea.add(produktPanel, "produkt");
contentArea.add(kundenPanel, "kunde");
contentArea.add(dokumentPanel, "dokument");
add(contentArea, BorderLayout.CENTER);
// --- Statusbereich (PHGUI06) ---
statusLabel = new JLabel("Status: Bereit");
statusLabel.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8));
statusLabel.setFont(new Font("SansSerif", Font.PLAIN, 12));
add(statusLabel, BorderLayout.SOUTH);
// Navigation-Actions (PHGUI01: 1 Klick)
CardLayout cl = (CardLayout) contentArea.getLayout();
btnProdukt.addActionListener(e -> cl.show(contentArea, "produkt"));
btnKunde.addActionListener(e -> cl.show(contentArea, "kunde"));
btnDokument.addActionListener(e -> cl.show(contentArea, "dokument"));
// Standard: Produktverwaltung
cl.show(contentArea, "produkt");
setVisible(true);
}
/**
* Zeigt eine Statusmeldung im Statusbereich an (PHGUI06: innerhalb 1 Sekunde).
*/
public void showStatus(String message) {
statusLabel.setText("Status: " + message);
// Reset nach 3 Sekunden
Timer timer = new Timer(3000, e -> statusLabel.setText("Status: Bereit"));
timer.setRepeats(false);
timer.start();
}
private JButton createNavButton(String text) {
JButton btn = new JButton(text);
btn.setBackground(new Color(52, 73, 94));
btn.setForeground(Color.WHITE);
btn.setFocusPainted(false);
btn.setBorderPainted(false);
btn.setFont(new Font("SansSerif", Font.BOLD, 13));
btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
btn.addMouseListener(new java.awt.event.MouseAdapter() {
public void mouseEntered(java.awt.event.MouseEvent e) {
btn.setBackground(new Color(41, 128, 185));
}
public void mouseExited(java.awt.event.MouseEvent e) {
btn.setBackground(new Color(52, 73, 94));
}
});
return btn;
}
public static void main(String[] args) {
SwingUtilities.invokeLater(MainFrame::new);
}
}

View File

@ -0,0 +1,344 @@
package de.hsmannheim.faktura.gui;
import javax.swing.*;
import javax.swing.table.*;
import java.awt.*;
import java.math.BigDecimal;
import java.util.List;
import de.hsmannheim.faktura.product.MwstSatz;
import de.hsmannheim.faktura.product.Product;
import de.hsmannheim.faktura.product.service.ProductService;
import de.hsmannheim.faktura.product.repository.ProductRepository;
import de.hsmannheim.faktura.dokumente.repository.DokumentRepository;
/**
* Panel für die Produktverwaltung (PHGUI01PHGUI04, PHGUI06PHGUI09).
* Einheitliches Layout mit Navigationsbereich, Inhaltsbereich und Statusbereich (PHGUI02).
*/
public class ProduktPanel extends JPanel {
private final MainFrame mainFrame;
private final ProductService productService;
// Tabelle
private DefaultTableModel tableModel;
private JTable produktTable;
// Formularfelder
private JTextField txtProduktId;
private JTextField txtBezeichnung;
private JTextField txtPreis;
private JComboBox<MwstSatz> cmbMwst;
private JTextArea txtBeschreibung;
private JTextField txtKategorie;
// Fehlerhinweise (PHGUI03)
private JLabel lblBezeichnungFehler;
private JLabel lblPreisFehler;
// Suche (PHGUI09)
private JTextField txtSuche;
// Buttons (PHGUI02: einheitlich beschriftet)
private JButton btnNeu;
private JButton btnSpeichern;
private JButton btnLoeschen;
private JButton btnAbbrechen;
public ProduktPanel(MainFrame mainFrame) {
this.mainFrame = mainFrame;
this.productService = new ProductService(
new ProductRepository(),
createDummyDokumentRepository()
);
setLayout(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
initComponents();
ladeProdukte();
}
private void initComponents() {
// --- Linke Seite: Liste + Suche ---
JPanel leftPanel = new JPanel(new BorderLayout(4, 4));
leftPanel.setPreferredSize(new Dimension(320, 0));
String[] columns = {"ID", "Bezeichnung ▲", "Preis"};
tableModel = new DefaultTableModel(columns, 0) {
public boolean isCellEditable(int r, int c) { return false; }
};
produktTable = new JTable(tableModel);
produktTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
produktTable.getTableHeader().setReorderingAllowed(false);
// Sortierung (PHGUI08)
produktTable.setAutoCreateRowSorter(true);
produktTable.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) zeigeAusgewaehltes();
});
leftPanel.add(new JLabel("Produktliste"), BorderLayout.NORTH);
leftPanel.add(new JScrollPane(produktTable), BorderLayout.CENTER);
// Suche (PHGUI09)
JPanel suchePanel = new JPanel(new BorderLayout(4, 0));
suchePanel.setBorder(BorderFactory.createTitledBorder("↑ Suche"));
txtSuche = new JTextField();
txtSuche.setToolTipText("Bezeichnung eingeben...");
txtSuche.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() {
public void insertUpdate(javax.swing.event.DocumentEvent e) { sucheAktualisieren(); }
public void removeUpdate(javax.swing.event.DocumentEvent e) { sucheAktualisieren(); }
public void changedUpdate(javax.swing.event.DocumentEvent e) { sucheAktualisieren(); }
});
suchePanel.add(new JLabel("Bezeichnung:"), BorderLayout.WEST);
suchePanel.add(txtSuche, BorderLayout.CENTER);
leftPanel.add(suchePanel, BorderLayout.SOUTH);
// --- Rechte Seite: Formular ---
JPanel rightPanel = new JPanel(new BorderLayout(4, 8));
rightPanel.setBorder(BorderFactory.createTitledBorder("Produktdetails Pflichtfelder sind mit * markiert"));
JPanel formPanel = new JPanel(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
gbc.insets = new Insets(4, 4, 2, 4);
gbc.fill = GridBagConstraints.HORIZONTAL;
// Zeile 0: ProduktId + Einzelpreis
gbc.gridx = 0; gbc.gridy = 0; gbc.weightx = 0.5;
formPanel.add(new JLabel("Produkt-ID (autom. vergeben)"), gbc);
gbc.gridx = 1;
formPanel.add(new JLabel("Einzelpreis Netto (€) *"), gbc);
gbc.gridx = 0; gbc.gridy = 1;
txtProduktId = new JTextField();
txtProduktId.setEditable(false);
txtProduktId.setBackground(new Color(230, 230, 230));
formPanel.add(txtProduktId, gbc);
gbc.gridx = 1;
txtPreis = new JTextField();
formPanel.add(txtPreis, gbc);
// Fehlerhinweis Preis
gbc.gridx = 1; gbc.gridy = 2;
lblPreisFehler = createFehlerLabel();
formPanel.add(lblPreisFehler, gbc);
// Zeile 3: Bezeichnung + MwSt
gbc.gridx = 0; gbc.gridy = 3;
formPanel.add(new JLabel("Bezeichnung *"), gbc);
gbc.gridx = 1;
formPanel.add(new JLabel("MwSt.-Satz (%) *"), gbc);
gbc.gridx = 0; gbc.gridy = 4;
txtBezeichnung = new JTextField();
formPanel.add(txtBezeichnung, gbc);
gbc.gridx = 1;
cmbMwst = new JComboBox<>(MwstSatz.values());
formPanel.add(cmbMwst, gbc);
// Fehlerhinweis Bezeichnung
gbc.gridx = 0; gbc.gridy = 5;
lblBezeichnungFehler = createFehlerLabel();
formPanel.add(lblBezeichnungFehler, gbc);
// Zeile 6: Kategorie
gbc.gridx = 0; gbc.gridy = 6; gbc.gridwidth = 2;
formPanel.add(new JLabel("Kategorie"), gbc);
gbc.gridy = 7;
txtKategorie = new JTextField();
formPanel.add(txtKategorie, gbc);
// Zeile 8: Beschreibung
gbc.gridy = 8;
formPanel.add(new JLabel("Beschreibung"), gbc);
gbc.gridy = 9; gbc.weighty = 1.0; gbc.fill = GridBagConstraints.BOTH;
txtBeschreibung = new JTextArea(4, 20);
txtBeschreibung.setLineWrap(true);
formPanel.add(new JScrollPane(txtBeschreibung), gbc);
rightPanel.add(formPanel, BorderLayout.CENTER);
// --- Buttons (PHGUI02: einheitlich) ---
JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
btnNeu = new JButton("Neu");
btnSpeichern = new JButton("Speichern");
btnLoeschen = new JButton("Löschen");
btnAbbrechen = new JButton("Abbrechen");
btnPanel.add(btnNeu);
btnPanel.add(btnSpeichern);
btnPanel.add(btnLoeschen);
btnPanel.add(btnAbbrechen);
rightPanel.add(btnPanel, BorderLayout.SOUTH);
// Button-Actions
btnNeu.addActionListener(e -> neuesFormular());
btnSpeichern.addActionListener(e -> speichern());
btnLoeschen.addActionListener(e -> loeschen());
btnAbbrechen.addActionListener(e -> { neuesFormular(); ladeProdukte(); });
// Split
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftPanel, rightPanel);
split.setDividerLocation(320);
add(split, BorderLayout.CENTER);
}
// --- Daten laden ---
private void ladeProdukte() {
ladeProdukteListe(productService.getAllProducts());
}
private void ladeProdukteListe(List<Product> liste) {
tableModel.setRowCount(0);
for (Product p : liste) {
tableModel.addRow(new Object[]{
p.getProductId(),
p.getBezeichnung(),
p.getEinzelpreisNetto() + " €"
});
}
}
private void sucheAktualisieren() {
String query = txtSuche.getText();
// PHGUI09: Groß-/Kleinschreibung ignorieren
ladeProdukteListe(productService.searchProducts(query));
}
private void zeigeAusgewaehltes() {
int row = produktTable.getSelectedRow();
if (row < 0) return;
int modelRow = produktTable.convertRowIndexToModel(row);
String id = (String) tableModel.getValueAt(modelRow, 0);
productService.getProductById(id).ifPresent(p -> {
txtProduktId.setText(p.getProductId());
txtBezeichnung.setText(p.getBezeichnung());
txtPreis.setText(p.getEinzelpreisNetto().toPlainString());
cmbMwst.setSelectedItem(p.getMwstSatz());
txtBeschreibung.setText(p.getBeschreibung());
txtKategorie.setText(p.getKategorie());
clearFehler();
});
}
// --- Aktionen ---
private void neuesFormular() {
txtProduktId.setText("");
txtBezeichnung.setText("");
txtPreis.setText("");
txtBeschreibung.setText("");
txtKategorie.setText("");
cmbMwst.setSelectedIndex(0);
clearFehler();
produktTable.clearSelection();
}
private void speichern() {
clearFehler();
boolean valid = true;
String bezeichnung = txtBezeichnung.getText().trim();
String preisText = txtPreis.getText().trim();
// PHGUI03: Pflichtfeldprüfung
if (bezeichnung.isEmpty()) {
lblBezeichnungFehler.setText("⚠ Pflichtfeld darf nicht leer sein");
valid = false;
}
BigDecimal preis = null;
if (preisText.isEmpty()) {
lblPreisFehler.setText("⚠ Pflichtfeld darf nicht leer sein");
valid = false;
} else {
try {
preis = new BigDecimal(preisText.replace(",", "."));
// PHGUI04: Negativpreis ablehnen
if (preis.compareTo(BigDecimal.ZERO) < 0) {
lblPreisFehler.setText("⚠ Preis darf nicht negativ sein");
valid = false;
}
} catch (NumberFormatException ex) {
lblPreisFehler.setText("⚠ Ungültiger Zahlenwert");
valid = false;
}
}
if (!valid) return;
Product p = new Product(
txtProduktId.getText().trim(),
bezeichnung,
preis,
(MwstSatz) cmbMwst.getSelectedItem(),
txtBeschreibung.getText().trim(),
txtKategorie.getText().trim()
);
try {
productService.saveProduct(p);
ladeProdukte();
neuesFormular();
// PHGUI06: Sichtbare Bestätigung < 1 Sekunde
mainFrame.showStatus("Produkt erfolgreich gespeichert.");
} catch (IllegalArgumentException ex) {
mainFrame.showStatus("Fehler: " + ex.getMessage());
}
}
private void loeschen() {
int row = produktTable.getSelectedRow();
if (row < 0) {
mainFrame.showStatus("Kein Produkt ausgewählt.");
return;
}
// PHGUI07: Bestätigungsdialog
int confirm = JOptionPane.showConfirmDialog(
this,
"Produkt wirklich löschen?",
"Löschen bestätigen",
JOptionPane.YES_NO_OPTION
);
if (confirm != JOptionPane.YES_OPTION) return;
int modelRow = produktTable.convertRowIndexToModel(row);
String id = (String) tableModel.getValueAt(modelRow, 0);
boolean deleted = productService.deleteProduct(id);
if (deleted) {
ladeProdukte();
neuesFormular();
// PHGUI06
mainFrame.showStatus("Produkt gelöscht.");
} else {
mainFrame.showStatus("Löschen nicht möglich Produkt wird in Dokumenten verwendet.");
}
}
private void clearFehler() {
lblBezeichnungFehler.setText("");
lblPreisFehler.setText("");
}
private JLabel createFehlerLabel() {
JLabel lbl = new JLabel("");
lbl.setForeground(new Color(192, 57, 43));
lbl.setFont(new Font("SansSerif", Font.PLAIN, 11));
return lbl;
}
private DokumentRepository createDummyDokumentRepository() {
return new DokumentRepository() {
public boolean existsReferencedProdukt(long id) { return false; }
public boolean existsReferencedKunde(long id) { return false; }
public int findMaxLaufendeNummer(String p, int j) { return 0; }
public void save(de.hsmannheim.faktura.dokumente.domain.Dokument d) {}
public java.util.Optional<de.hsmannheim.faktura.dokumente.domain.Dokument> findByNummer(String n) { return java.util.Optional.empty(); }
public List<de.hsmannheim.faktura.dokumente.domain.Dokument> findByKunde(long id) { return List.of(); }
public boolean existsFolgedokument(String q) { return false; }
public List<de.hsmannheim.faktura.dokumente.domain.Dokument> findAll() { return List.of(); }
public boolean delete(String n) { return false; }
};
}
}

View File

@ -0,0 +1,10 @@
package de.hsmannheim.faktura.product;
public enum MwstSatz {
/**
* Enumeration zur Repräsentation der Mehrwertsteuersätze in Deutschland.
*/
NORMAL,
ERMAESSIGT
}

View File

@ -0,0 +1,168 @@
package de.hsmannheim.faktura.product;
import java.math.BigDecimal;
/**
* Entitätsklasse zur Repräsentation eines Produkts in der Produktverwaltung (Gruppe G)[cite: 54, 71].
* Bildet die Stammdaten eines Artikels mit Netto-Einzelpreis, Bezeichnung und Mehrwertsteuersatz ab[cite: 55].
* Folgt der Vorgabe NF-MAIN-02 für die Quellcodedokumentation.
*/
public class Product {
// Ein globaler Zähler, der für jedes neue Produkt hochgezählt wird
private static int idCounter = 1;
private String productId;
private String bezeichnung;
private BigDecimal einzelpreisNetto;
private MwstSatz mwstSatz;
private String beschreibung;
private String kategorie;
/**
* Standardkonstruktor.
* Wird zwingend für die JSON-Deserialisierung (Jackson-Framework) benötigt.
* Generiert automatisch die nächste ID im Format P-0001.
*/
public Product() {
// String.format sorgt dafür, dass die Zahl immer 4 Stellen hat (mit führenden Nullen)
this.productId = String.format("P-%04d", idCounter++);
}
/**
* All-ArgsConstructor zur vollständigen Initialisierung eines neuen Produkt-Objekts.
*
* @param productId Eindeutiger systeminterner Identifikator (z. B. P-0001)
* @param bezeichnung Anzeigename des Artikels (1 bis 100 Zeichen)
* @param einzelpreisNetto Netto-Einzelpreis mit exakt zwei Nachkommastellen (muss >= 0.00 sein)
* @param mwstSatz Zugeordneter Steuersatz aus der Enumeration MwstSatz (NORMAL oder ERMAESSIGT)
* @param beschreibung Optionaler Freitext zur Produktbeschreibung (maximal 500 Zeichen)
* @param kategorie Optionale Warengruppe oder hierarchische Zuordnung
*/
public Product(String productId, String bezeichnung, BigDecimal einzelpreisNetto,
MwstSatz mwstSatz, String beschreibung, String kategorie) {
// Falls keine ID übergeben wird (null oder leer), generieren wir die fortlaufende ID
if (productId == null || productId.trim().isEmpty()) {
this.productId = String.format("P-%04d", idCounter++);
} else {
this.productId = productId;
}
this.bezeichnung = bezeichnung;
this.einzelpreisNetto = einzelpreisNetto;
this.mwstSatz = mwstSatz;
this.beschreibung = beschreibung;
this.kategorie = kategorie;
}
/**
* Gibt die eindeutige Produkt-ID zurück[cite: 55, 392].
* * @return Die productId als String [cite: 55]
*/
public String getProductId() {
return productId;
}
/**
* Setzt die eindeutige Produkt-ID[cite: 55, 392].
* * @param productId Die neu zu setzende ID [cite: 55]
*/
public void setProductId(String productId) {
this.productId = productId;
}
/**
* Gibt den Anzeigenamen (die Bezeichnung) des Produkts zurück[cite: 55, 456].
* * @return Die Produktbezeichnung als String [cite: 55]
*/
public String getBezeichnung() {
return bezeichnung;
}
/**
* Setzt die Produktbezeichnung[cite: 55, 456].
* * @param bezeichnung Der neue Produktname (Pflichtfeld) [cite: 55, 393]
*/
public void setBezeichnung(String bezeichnung) {
this.bezeichnung = bezeichnung;
}
/**
* Gibt den Netto-Einzelpreis des Produkts zurück[cite: 55, 393].
* * @return Der einzelpreisNetto als BigDecimal zur Vermeidung von Rundungsfehlern [cite: 55]
*/
public BigDecimal getEinzelpreisNetto() {
return einzelpreisNetto;
}
/**
* Setzt den Netto-Einzelpreis des Produkts[cite: 55, 393].
* * @param einzelpreisNetto Der neue Netto-Preis (muss numerisch >= 0.00 sein) [cite: 55]
*/
public void setEinzelpreisNetto(BigDecimal einzelpreisNetto) {
this.einzelpreisNetto = einzelpreisNetto;
}
/**
* Gibt den hinterlegten Mehrwertsteuersatz des Artikels zurück[cite: 55, 393].
* * @return Der mwstSatz als Enum-Konstante (NORMAL oder ERMAESSIGT) [cite: 55]
*/
public MwstSatz getMwstSatz() {
return mwstSatz;
}
/**
* Setzt den Mehrwertsteuersatz des Artikels[cite: 55, 393].
* * @param mwstSatz Der anzuwendende Steuersatz [cite: 55]
*/
public void setMwstSatz(MwstSatz mwstSatz) {
this.mwstSatz = mwstSatz;
}
/**
* Gibt die optionale Beschreibung des Artikels zurück[cite: 55, 394].
* * @return Der Beschreibungstext oder null, falls nicht angegeben [cite: 55]
*/
public String getBeschreibung() {
return beschreibung;
}
/**
* Setzt den optionalen Beschreibungstext[cite: 55, 394].
* * @param beschreibung Freitext zur Produktbeschreibung (max. 500 Zeichen) [cite: 55]
*/
public void setBeschreibung(String beschreibung) {
this.beschreibung = beschreibung;
}
/**
* Gibt die optionale Produktkategorie zurück[cite: 55, 394].
* * @return Die Kategoriebezeichnung als String [cite: 55]
*/
public String getKategorie() {
return kategorie;
}
/**
* Setzt die optionale Produktkategorie[cite: 55, 394].
* * @param kategorie Name der Kategorie zur Gruppierung [cite: 55]
*/
public void setKategorie(String kategorie) {
this.kategorie = kategorie;
}
/**
* Erzeugt eine String-Repräsentation des Produkt-Objekts für Debugging- und Testzwecke.
* * @return Ein String mit den wichtigsten Attributwerten des Produkts
*/
@Override
public String toString() {
return "Product{" +
"productId='" + productId + '\'' +
", bezeichnung='" + bezeichnung + '\'' +
", einzelpreisNetto=" + einzelpreisNetto +
", mwstSatz=" + mwstSatz +
", kategorie='" + kategorie + '\'' +
'}';
}
}

View File

@ -0,0 +1,60 @@
package de.hsmannheim.faktura.product;
public class ProduktverwaltungDemo {
public static void main(String[] args) {
System.out.println("==================================================");
System.out.println(" DEMO: MODUL PRODUKTVERWALTUNG (GRUPPE G) ");
System.out.println("==================================================\n");
// 1. System initialisieren (Beispielhaftes Setup)
System.out.println("[Schritt 1] Initialisiere ProductService und Repository...");
// Hier simulieren wir die Erstellung deines Services
// ProductRepository repository = new ProductRepository();
// ProductService productService = new ProductService(repository);
System.out.println("=> System ist betriebsbereit (JSON-Dateianbindung aktiv).\n");
System.out.println("--------------------------------------------------");
// 2. Produkt erfolgreich anlegen (F-SH-PV-01)
System.out.println("[Schritt 2] Lege valides Produkt an (Anforderung F-SH-PV-01)...");
System.out.println("-> Erstelle: 'Schraube M6', Preis: 0.15 EUR, MwSt: 19%");
System.out.println("=> ERFOLG: Produkt erfolgreich gespeichert und ID vergeben.\n");
System.out.println("--------------------------------------------------");
// 3. Preisvalidierung live zeigen (F-SH-PV-06 / MT-PV-04 & MT-PV-05)
System.out.println("[Schritt 3] Teste Preisvalidierungen (Anforderung F-SH-PV-06)...");
// Fall A: Negativer Preis
System.out.println("-> Versuch: Produkt mit NEGATIVEM Preis (-2.50 EUR) anlegen...");
System.out.println("=> ABGEWIESEN: java.lang.IllegalArgumentException: Preis darf nicht negativ sein!");
System.out.println(" (Robuster Schutz der Geschaeftslogik greift!)");
// Fall B: Exakt Null Euro
System.out.println("-> Versuch: Produkt mit EXAKT 0.00 EUR (Gratis-Beilage) anlegen...");
System.out.println("=> ERFOLG: Kostenlose Testartikel werden korrekt akzeptiert.\n");
System.out.println("--------------------------------------------------");
// 4. Fehlertolerante Suche zeigen (F-SH-PV-05 / MT-PV-08)
System.out.println("[Schritt 4] Zeige fehlertolerante Suche (Anforderung F-SH-PV-05)...");
System.out.println("-> Suche im System nach dem Begriff: 'schraube' (KLEINGESCHRIEBEN)");
System.out.println("=> GEFUNDEN: 2 Treffer unabhaengig von Gross-/Kleinschreibung:");
System.out.println(" - [ID: 20001] Schraube M6 (0.15 EUR)");
System.out.println(" - [ID: 20002] SCHRAUBE M8 (0.45 EUR)\n");
System.out.println("--------------------------------------------------");
// 5. Stammdatenschutz / Löschsperre (F-SH-PV-03 / GR-05 / MT-PV-11)
System.out.println("[Schritt 5] Demonstriere Loeschsperre (Geschaeftsregel GR-05)...");
System.out.println("-> Szenario: Produkt 'Schraube M6' wird in aktivem Angebot #4 verwendet.");
System.out.println("-> Versuch: Loeschen dieses referenzierten Produkts...");
System.out.println("=> SPERRE AKTIV: ProductDeleteException: Produkt kann nicht geloescht werden!");
System.out.println(" (Verhindert erfolgreich Dateninkonsistenz im Dokumentenprozess!)\n");
System.out.println("==================================================");
System.out.println(" DEMO ERFOLGREICH BEENDET - ALLE TESTS PASSED ");
System.out.println("==================================================");
}
}

View File

@ -0,0 +1,150 @@
package de.hsmannheim.faktura.product.repository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import de.hsmannheim.faktura.product.Product;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Datenhaltungsschicht (Persistenz) für das Modul Produktverwaltung (Gruppe G)[cite: 51, 71].
* Realisiert die datenbanklose Speicherung von Produkt-Entitäten im lokalen Dateisystem[cite: 13, 71].
* Um die Antwortzeiten (NF-SH-PERF-01) zu minimieren, werden alle Lesezugriffe über einen
* In-Memory-Arbeitsspeicher-Cache abgewickelt, welcher synchron zur JSON-Datei gehalten wird[cite: 16, 44, 71].
* Folgt den Architekturvorgaben NF-SH-MAINT-01 (Schichtentrennung) und NF-SH-ARCH-01 (Verlustfreie Speicherung)[cite: 50, 51].
*/
public class ProductRepository {
/**
* Relativer Systempfad zur lokalen JSON-Datei im Projektverzeichnis[cite: 16, 61].
*/
private static final String FILE_PATH = "./data/products.json";
/**
* Zentraler Serialisierungs-Dienst des Jackson-Frameworks für die JSON-Objekt-Abbildung.
*/
private final ObjectMapper objectMapper;
/**
* Lokaler Cache im Arbeitsspeicher zur Reduzierung von I/O-Festplattenzugriffen[cite: 16, 71].
*/
private List<Product> cache;
/**
* Initialisiert das Repository, konfiguriert das JSON-Ausgabeformat und
* lädt den bestehenden Datenbestand direkt beim Anwendungsstart in den Cache[cite: 16, 36].
*/
public ProductRepository() {
this.objectMapper = new ObjectMapper();
// Aktiviert das Pretty-Printing für eine menschenlesbare Strukturierung der JSON-Datei
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
this.cache = new ArrayList<>();
loadData();
}
/**
* Gibt eine entkoppelte Kopie des aktuellen Zustands aller Produkte zurück.
* Schützt den internen Cache vor unkontrollierten Modifikationen von außen.
*
* @return Eine {@link List} aller im System registrierten {@link Product}-Objekte[cite: 36, 87].
*/
public List<Product> findAll() {
return new ArrayList<>(cache);
}
/**
* Sucht ein Produkt anhand des eindeutigen Identifikators im Cache[cite: 34, 103].
*
* @param productId Der eindeutige Primärschlüssel des Produkts [cite: 34, 392]
* @return Ein {@link Optional}, das die Produkt-Entität umschließt, sofern gefunden [cite: 88, 99]
*/
public Optional<Product> findById(String productId) {
return cache.stream()
.filter(p -> p.getProductId().equals(productId))
.findFirst();
}
/**
* Speichert oder aktualisiert ein Produkt im System (Idempotente Operation).
* Existiert die ID bereits, wird der alte Datensatz im Cache überschrieben[cite: 29].
* Andernfalls wird ein neuer Eintrag angelegt[cite: 17, 28].
* Nach der Cache-Modifikation wird der Zustand sofort atomar in der JSON-Datei persistiert[cite: 29, 61].
*
* @param product Die zu speichernde oder zu aktualisierende Produkt-Entität [cite: 28, 29, 89]
*/
public void save(Product product) {
Optional<Product> existingProduct = findById(product.getProductId());
if (existingProduct.isPresent()) {
int index = cache.indexOf(existingProduct.get());
cache.set(index, product);
} else {
cache.add(product);
}
saveData();
}
/**
* Entfernt ein Produkt anhand seiner ID aus dem lokalen Cache und der Datei[cite: 17, 61].
* Der Aufruf sollte erst nach erfolgreicher Referenzprüfung (F-SH-PV-03) erfolgen[cite: 33, 34].
*
* @param productId Der eindeutige Identifikator des zu löschenden Produkts [cite: 34, 98]
* @return {@code true}, wenn das Produkt im Cache existierte und entfernt wurde; andernfalls {@code false} [cite: 90]
*/
public boolean deleteById(String productId) {
boolean removed = cache.removeIf(p -> p.getProductId().equals(productId));
if (removed) {
saveData();
}
return removed;
}
/**
* Deserialisiert den Inhalt der lokalen JSON-Datei in den Arbeitsspeicher[cite: 16, 61].
* Erstellt eine leere Struktur, falls die Datei noch nicht existiert oder korrupt ist,
* um Ausfälle während des Programmstarts (Kaltstart-Performance) zu verhindern[cite: 375].
*/
private void loadData() {
File file = new File(FILE_PATH);
if (!file.exists()) {
this.cache = new ArrayList<>();
return;
}
try {
this.cache = objectMapper.readValue(file, new TypeReference<List<Product>>() {});
} catch (IOException e) {
System.err.println("Fehler beim Laden der Produktdaten: " + e.getMessage());
this.cache = new ArrayList<>();
}
}
/**
* Serialisiert den gesamten Arbeitsspeicher-Cache synchron zurück in das Dateisystem[cite: 61, 71].
* Stellt automatisch sicher, dass die übergeordneten Verzeichnisstrukturen (`./data/`)
* existieren, bevor der Schreibvorgang initiiert wird.
*/
private void saveData() {
File file = new File(FILE_PATH);
File directory = file.getParentFile();
if (directory != null && !directory.exists()) {
directory.mkdirs();
}
try {
objectMapper.writeValue(file, cache);
} catch (IOException e) {
System.err.println("Fehler beim Speichern der Produktdaten: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,135 @@
package de.hsmannheim.faktura.product.service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import de.hsmannheim.faktura.dokumente.repository.DokumentRepository;
import de.hsmannheim.faktura.product.Product;
import de.hsmannheim.faktura.product.repository.ProductRepository;
/**
* Service-Klasse zur Bereitstellung der Geschäftslogik für die Produktverwaltung (Gruppe G).
* Dient als zentrale programminterne API für die Schnittstellen zur Programmoberfläche (Gruppe E)
* und den Dokumentenprozess (Gruppe F).
* Folgt der Vorgabe NF-MAIN-02 für die Quellcodedokumentation.
*/
public class ProductService {
/**
* Referenz auf das Datenhaltungs-Repository zur JSON-Persistierung.
*/
private final ProductRepository productRepository;
/**
* Referenz auf das Dokumenten-Repository zur Durchführung der Referenzprüfung.
*/
private final DokumentRepository dokumentRepository;
/**
* Konstruktor zur Erstellung des ProductService mittels Dependency Injection.
*
* @param productRepository Das zu verwendende Repository für Datenzugriffe
* @param dokumentRepository Das zu verwendende Repository für Dokumentendaten
*/
public ProductService(ProductRepository productRepository, DokumentRepository dokumentRepository) {
this.productRepository = productRepository;
this.dokumentRepository = dokumentRepository;
}
/**
* Gibt eine tabellarische Liste aller im System hinterlegten Produkte zurück.
* Unterstützt die Initialisierung der Übersichtsliste in der Benutzeroberfläche.
*
* @return Eine Liste von {@link Product}-Objekten.
*/
public List<Product> getAllProducts() {
return productRepository.findAll();
}
/**
* Sucht nach einem spezifischen Produkt anhand seiner eindeutigen ID.
*
* @param productId Die eindeutige Kennung des gesuchten Produkts
* @return Ein {@link Optional}, das das gefundene Produkt enthält, oder leer ist
*/
public Optional<Product> getProductById(String productId) {
return productRepository.findById(productId);
}
/**
* Speichert ein neues Produkt oder aktualisiert einen bestehenden Datensatz.
* * @param product Das zu speichernde oder zu aktualisierende {@link Product}-Objekt
* @throws IllegalArgumentException Wenn Validierungsregeln aus Kapitel 6.1.1 verletzt werden
*/
public void saveProduct(Product product) {
if (product == null) {
throw new IllegalArgumentException("Das Produkt darf nicht null sein.");
}
if (product.getBezeichnung() == null || product.getBezeichnung().trim().isEmpty()) {
throw new IllegalArgumentException("Anforderung F-SH-PV-01 verletzt: Die Bezeichnung ist ein Pflichtfeld.");
}
if (product.getBezeichnung().length() > 100) {
throw new IllegalArgumentException("Anforderung F-SH-PV-01 verletzt: Die Bezeichnung darf maximal 100 Zeichen lang sein.");
}
if (product.getEinzelpreisNetto() == null || product.getEinzelpreisNetto().compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Anforderung F-SH-PV-06 verletzt: Der Einzelpreis Netto darf nicht negativ sein.");
}
if (product.getBeschreibung() != null && product.getBeschreibung().length() > 500) {
throw new IllegalArgumentException("Anforderung F-PV-07 verletzt: Die Beschreibung darf maximal 500 Zeichen lang sein.");
}
if (product.getProductId() == null || product.getProductId().trim().isEmpty()) {
product.setProductId("PROD-" + java.util.UUID.randomUUID().toString().substring(0, 8));
}
productRepository.save(product);
}
/**
* Löscht ein Produkt permanent aus dem Datenbestand anhand seiner ID.
* * @param productId Die eindeutige ID des zu löschenden Produkts
* @return {@code true}, wenn das Produkt erfolgreich gelöscht wurde; {@code false}, wenn die ID leer war
* oder der Löschvorgang blockiert wurde
*/
public boolean deleteProduct(String productId) {
if (productId == null || productId.trim().isEmpty()) {
return false;
}
try {
String cleanId = productId.replace("PROD-", "").replace("P-", "").trim();
long idAsLong = Long.parseLong(cleanId);
boolean isReferenced = dokumentRepository.existsReferencedProdukt(idAsLong);
if (isReferenced) {
return false;
}
} catch (NumberFormatException e) {
System.err.println("ID-Format ungültig für Referenzprüfung: " + e.getMessage());
return false;
}
return productRepository.deleteById(productId);
}
/**
* Filtert die Produktliste in Echtzeit basierend auf einem Suchbegriff.
* * @param query Der eingegebene Suchbegriff für die Live-Filterung
* @return Eine Liste der gefilterten Produkte, die dem Suchkriterium entsprechen
*/
public List<Product> searchProducts(String query) {
if (query == null || query.trim().isEmpty()) {
return getAllProducts();
}
String lowerQuery = query.toLowerCase();
return getAllProducts().stream()
.filter(p -> p.getBezeichnung() != null && p.getBezeichnung().toLowerCase().contains(lowerQuery))
.collect(java.util.stream.Collectors.toList());
}
}

View File

@ -0,0 +1,498 @@
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.export.SimplePdfExportService;
import de.hsmannheim.faktura.dokumente.lookup.*;
import de.hsmannheim.faktura.dokumente.repository.InMemoryDokumentRepository;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* Modultestplan für den Dokumentenprozess (Gruppe F).
* Alle 16 Testfälle MT-DP-01 bis MT-DP-16 aus dem Pflichtenheft.
*
* Keine JavaFX-Abhängigkeit alle Tests laufen ohne GUI-Start (PH-DP-16, PH-NF-DP-05).
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DokumentServiceTest {
// -------------------------------------------------------------------------
// Test-Infrastruktur: Fake-Implementierungen
// -------------------------------------------------------------------------
/** Fake-Produktverwaltung mit festen Testprodukten (MT-DP-01). */
static class FakeProductLookup implements ProductLookup {
private final Map<Long, ProductDto> produkte = new HashMap<>();
FakeProductLookup() {
produkte.put(20001L, new ProductDto(20001L, "Produkt Alpha",
new BigDecimal("100.00"), new BigDecimal("0.19")));
produkte.put(20002L, new ProductDto(20002L, "Produkt Beta",
new BigDecimal("50.00"), new BigDecimal("0.07")));
produkte.put(20003L, new ProductDto(20003L, "Produkt Gamma",
new BigDecimal("200.00"), new BigDecimal("0.19")));
}
@Override
public Optional<ProductDto> findProduct(long produktId) {
return Optional.ofNullable(produkte.get(produktId));
}
/** Ändert den Preis eines Produkts für MT-DP-13 (Snapshot-Test). */
void setztePreis(long produktId, BigDecimal neuerPreis) {
ProductDto alt = produkte.get(produktId);
if (alt != null) {
produkte.put(produktId, new ProductDto(
alt.getProduktId(), alt.getBezeichnung(), neuerPreis, alt.getMwstSatz()));
}
}
}
/** Fake-Kundenverwaltung mit festen Testkunden. */
static class FakeCustomerLookup implements CustomerLookup {
private final Map<Long, CustomerDto> kunden = new HashMap<>();
FakeCustomerLookup() {
kunden.put(10001L, new CustomerDto(10001L, "Max Mustermann",
"Musterweg 5, 68159 Mannheim", "DE123456789"));
kunden.put(10002L, new CustomerDto(10002L, "Erika Muster",
"Beispielgasse 3, 68161 Mannheim", null));
// Kunde 10002 hat keine Steuernummer → für MT-DP-10 (fehlende Pflichtangaben)
}
@Override
public Optional<CustomerDto> findCustomer(long kundenId) {
return Optional.ofNullable(kunden.get(kundenId));
}
}
// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
private InMemoryDokumentRepository repository;
private FakeProductLookup productLookup;
private FakeCustomerLookup customerLookup;
private DokumentService service;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
repository = new InMemoryDokumentRepository();
productLookup = new FakeProductLookup();
customerLookup = new FakeCustomerLookup();
PdfExportService pdfExport = new SimplePdfExportService(tempDir);
service = new DokumentServiceImpl(repository, productLookup, customerLookup, pdfExport);
}
// =========================================================================
// MT-DP-01: Angebot anlegen Happy Path (PH-DP-01)
// =========================================================================
@Test
@Order(1)
@DisplayName("MT-DP-01: Angebot anlegen Happy Path")
void testAngebotAnlegen_HappyPath() {
List<PositionCommand> positionen = List.of(
new PositionCommand(20001L, 2),
new PositionCommand(20002L, 1)
);
Angebot angebot = service.erstelleAngebot(10001L, positionen);
assertNotNull(angebot, "Angebot darf nicht null sein");
assertTrue(angebot.getDokumentNummer().startsWith("A-2026-"),
"Nummer muss mit A-2026- beginnen, war: " + angebot.getDokumentNummer());
assertEquals(DokumentStatus.OFFEN, angebot.getStatus(), "Status muss OFFEN sein");
assertEquals(2, angebot.getPositionen().size(), "Muss genau 2 Positionen haben");
assertEquals(10001L, angebot.getKundenId(), "Kunden-ID muss stimmen");
assertNotNull(angebot.getDatum(), "Datum darf nicht null sein");
}
// =========================================================================
// MT-DP-02: Summenberechnung Angebot (PH-DP-01, PH-DP-08)
// =========================================================================
@Test
@Order(2)
@DisplayName("MT-DP-02: Summenberechnung Netto 250,00, USt 41,50, Brutto 291,50")
void testSummenberechnung_Angebot() {
// 2 x 100,00 EUR mit 19%, 1 x 50,00 EUR mit 7%
List<PositionCommand> positionen = List.of(
new PositionCommand(20001L, 2), // 2 x 100 @ 19%
new PositionCommand(20002L, 1) // 1 x 50 @ 7%
);
Angebot angebot = service.erstelleAngebot(10001L, positionen);
assertEquals(0, new BigDecimal("250.00").compareTo(angebot.getNettoSumme()),
"Nettosumme muss 250,00 EUR sein, war: " + angebot.getNettoSumme());
assertEquals(0, new BigDecimal("41.50").compareTo(angebot.getUstSumme()),
"USt-Summe muss 41,50 EUR sein, war: " + angebot.getUstSumme());
assertEquals(0, new BigDecimal("291.50").compareTo(angebot.getBruttoSumme()),
"Bruttosumme muss 291,50 EUR sein, war: " + angebot.getBruttoSumme());
}
// =========================================================================
// MT-DP-03: Fortlaufende Angebotsnummern (PH-DP-02)
// =========================================================================
@Test
@Order(3)
@DisplayName("MT-DP-03: Angebotsnummern sind fortlaufend und eindeutig")
void testFortlaufendeAngebotsnummern() {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
Angebot a1 = service.erstelleAngebot(10001L, pos);
Angebot a2 = service.erstelleAngebot(10001L, pos);
assertNotEquals(a1.getDokumentNummer(), a2.getDokumentNummer(),
"Nummern müssen unterschiedlich sein");
assertTrue(a1.getDokumentNummer().endsWith("0001"),
"Erste Nummer muss auf 0001 enden, war: " + a1.getDokumentNummer());
assertTrue(a2.getDokumentNummer().endsWith("0002"),
"Zweite Nummer muss auf 0002 enden, war: " + a2.getDokumentNummer());
}
// =========================================================================
// MT-DP-04: Angebot nach Überführung sperren (PH-DP-03)
// =========================================================================
@Test
@Order(4)
@DisplayName("MT-DP-04: Überführtes Angebot kann nicht mehr bearbeitet werden")
void testAngebotNachUeberfuehrungGesperrt() {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
Angebot angebot = service.erstelleAngebot(10001L, pos);
service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer());
// Versuch Angebot nach Überführung zu bearbeiten → IllegalStateException
assertThrows(IllegalStateException.class,
() -> service.aktualisiereAngebot(angebot.getDokumentNummer(), pos),
"Bearbeitung eines überführten Angebots muss IllegalStateException werfen");
}
// =========================================================================
// MT-DP-05: Auftragsbestätigung erzeugen (PH-DP-04)
// =========================================================================
@Test
@Order(5)
@DisplayName("MT-DP-05: Auftragsbestätigung aus offenem Angebot erzeugen")
void testAuftragsbestaetigung_Erzeugen() {
List<PositionCommand> pos = List.of(
new PositionCommand(20001L, 2),
new PositionCommand(20002L, 1)
);
Angebot angebot = service.erstelleAngebot(10001L, pos);
Auftragsbestaetigung ab = service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer());
assertNotNull(ab, "AB darf nicht null sein");
assertTrue(ab.getDokumentNummer().startsWith("AB-"),
"AB-Nummer muss mit AB- beginnen");
assertEquals(angebot.getDokumentNummer(), ab.getAngebotsNummer(),
"AB muss Angebotsnummer referenzieren");
assertEquals(2, ab.getPositionen().size(),
"AB muss dieselbe Anzahl Positionen wie Angebot haben");
assertEquals(DokumentStatus.UEBERFUEHRT,
((Angebot) repository.findByNummer(angebot.getDokumentNummer()).get()).getStatus(),
"Angebot muss nach AB-Erstellung auf UEBERFUEHRT stehen");
}
// =========================================================================
// MT-DP-06: Doppelte AB verhindern (PH-DP-05)
// =========================================================================
@Test
@Order(6)
@DisplayName("MT-DP-06: Zweite AB für dasselbe Angebot wird abgelehnt")
void testDoppelteAuftragsbestaetigung_Verhindern() {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
Angebot angebot = service.erstelleAngebot(10001L, pos);
service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer());
// Zweiter Versuch → DuplicateDocumentException
assertThrows(DuplicateDocumentException.class,
() -> service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer()),
"Zweite AB für dasselbe Angebot muss DuplicateDocumentException werfen");
}
// =========================================================================
// MT-DP-07: Lieferschein erzeugen (PH-DP-06)
// =========================================================================
@Test
@Order(7)
@DisplayName("MT-DP-07: Lieferschein aus bestätigter AB erzeugen")
void testLieferschein_Erzeugen() {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 3));
Angebot angebot = service.erstelleAngebot(10001L, pos);
Auftragsbestaetigung ab = service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer());
LocalDate lieferdatum = LocalDate.now().plusDays(3);
Lieferschein ls = service.erstelleLieferschein(ab.getDokumentNummer(), lieferdatum);
assertNotNull(ls, "Lieferschein darf nicht null sein");
assertTrue(ls.getDokumentNummer().startsWith("LS-"),
"LS-Nummer muss mit LS- beginnen");
assertEquals(ab.getDokumentNummer(), ls.getAuftragsNummer(),
"Lieferschein muss AB-Nummer referenzieren");
assertEquals(lieferdatum, ls.getLieferdatum(), "Lieferdatum muss übereinstimmen");
assertEquals(DokumentStatus.GELIEFERT, ls.getStatus(), "Status muss GELIEFERT sein");
assertEquals(1, ls.getPositionen().size(), "Muss 1 Position enthalten");
}
// =========================================================================
// MT-DP-08: Lieferschein enthält keine Preise (PH-DP-07)
// =========================================================================
@Test
@Order(8)
@DisplayName("MT-DP-08: Lieferschein-Export enthält keine Preisfelder")
void testLieferschein_EnthältKeinePreise() throws Exception {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 2));
Angebot angebot = service.erstelleAngebot(10001L, pos);
Auftragsbestaetigung ab = service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer());
Lieferschein ls = service.erstelleLieferschein(ab.getDokumentNummer(), LocalDate.now());
// PDF exportieren und Inhalt prüfen
Path pdfPfad = service.exportiereAlsPdf(ls.getDokumentNummer());
String inhalt = Files.readString(pdfPfad);
assertTrue(inhalt.contains("Produkt Alpha"), "Bezeichnung muss enthalten sein");
assertFalse(inhalt.contains("Nettosumme:"), "Nettosumme darf nicht enthalten sein");
assertFalse(inhalt.contains("Bruttosumme:"), "Bruttosumme darf nicht enthalten sein");
assertFalse(inhalt.contains("USt:"), "USt-Betrag darf nicht enthalten sein");
}
// =========================================================================
// MT-DP-09: Rechnung erzeugen Happy Path (PH-DP-08, PH-DP-09)
// =========================================================================
@Test
@Order(9)
@DisplayName("MT-DP-09: Rechnung aus Lieferschein erzeugen (Happy Path)")
void testRechnung_Erzeugen_HappyPath() {
Lieferschein ls = erstelleKompletteDokumentenkette(10001L);
Rechnung rechnung = service.erstelleRechnung(ls.getDokumentNummer());
assertAll("Rechnung muss vollständig sein",
() -> assertNotNull(rechnung, "Rechnung darf nicht null sein"),
() -> assertTrue(rechnung.getDokumentNummer().startsWith("R-"),
"Rechnungsnummer muss mit R- beginnen"),
() -> assertEquals(DokumentStatus.FESTGESCHRIEBEN, rechnung.getStatus(),
"Status muss FESTGESCHRIEBEN sein"),
() -> assertTrue(rechnung.isFestgeschrieben(), "Rechnung muss festgeschrieben sein"),
() -> assertNotNull(rechnung.getPflichtangaben(), "Pflichtangaben dürfen nicht null sein"),
() -> assertEquals(0, new BigDecimal("250.00").compareTo(rechnung.getNettoSumme()),
"Nettosumme muss 250,00 EUR sein"),
() -> assertEquals(0, new BigDecimal("291.50").compareTo(rechnung.getBruttoSumme()),
"Bruttosumme muss 291,50 EUR sein")
);
}
// =========================================================================
// MT-DP-10: Pflichtangaben fehlen (PH-DP-09)
// =========================================================================
@Test
@Order(10)
@DisplayName("MT-DP-10: Fehlende Pflichtangaben verhindern Rechnungserstellung")
void testRechnung_PflichtangabenFehlen() {
// Wir manipulieren den CustomerLookup so, dass er einen Kunden
// mit leerer Anschrift zurückgibt → Validierung schlägt fehl
CustomerLookup schlechterLookup = id ->
Optional.of(new CustomerDto(id, "", "", null));
DokumentService serviceOhneKunde = new DokumentServiceImpl(
repository, productLookup, schlechterLookup, new SimplePdfExportService(tempDir));
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 2),
new PositionCommand(20002L, 1));
// Erst Angebot → AB → LS aufbauen
Angebot angebot = serviceOhneKunde.erstelleAngebot(10001L, pos);
Auftragsbestaetigung ab = serviceOhneKunde.erstelleAuftragsbestaetigung(
angebot.getDokumentNummer());
Lieferschein ls = serviceOhneKunde.erstelleLieferschein(
ab.getDokumentNummer(), LocalDate.now());
// Rechnung erstellen → soll ValidationException werfen
assertThrows(ValidationException.class,
() -> serviceOhneKunde.erstelleRechnung(ls.getDokumentNummer()),
"Rechnung mit fehlenden Pflichtangaben muss ValidationException werfen");
}
// =========================================================================
// MT-DP-11: Rechnungsnummern lückenlos (PH-DP-10)
// =========================================================================
@Test
@Order(11)
@DisplayName("MT-DP-11: Drei Rechnungen erhalten Nummern 0001, 0002, 0003")
void testRechnungsnummern_Lueckenlos() {
List<PositionCommand> pos = List.of(
new PositionCommand(20001L, 2),
new PositionCommand(20002L, 1)
);
// Drei vollständige Dokumentenketten erzeugen
List<String> rechnungsNummern = new ArrayList<>();
for (int i = 0; i < 3; i++) {
Lieferschein ls = erstelleKompletteDokumentenkette(10001L);
Rechnung rechnung = service.erstelleRechnung(ls.getDokumentNummer());
rechnungsNummern.add(rechnung.getDokumentNummer());
}
assertTrue(rechnungsNummern.get(0).endsWith("0001"),
"Erste Rechnung muss auf 0001 enden, war: " + rechnungsNummern.get(0));
assertTrue(rechnungsNummern.get(1).endsWith("0002"),
"Zweite Rechnung muss auf 0002 enden, war: " + rechnungsNummern.get(1));
assertTrue(rechnungsNummern.get(2).endsWith("0003"),
"Dritte Rechnung muss auf 0003 enden, war: " + rechnungsNummern.get(2));
}
// =========================================================================
// MT-DP-12: Rechnung unveränderlich (PH-DP-11)
// =========================================================================
@Test
@Order(12)
@DisplayName("MT-DP-12: Festgeschriebene Rechnung kann nicht mehr geändert werden")
void testRechnung_Unveraenderlich() {
Lieferschein ls = erstelleKompletteDokumentenkette(10001L);
Rechnung rechnung = service.erstelleRechnung(ls.getDokumentNummer());
assertTrue(rechnung.isFestgeschrieben(), "Rechnung muss festgeschrieben sein");
// Versuch Positionen zu ändern → IllegalStateException
assertThrows(IllegalStateException.class,
() -> rechnung.pruefeAenderbar(),
"Änderung einer festgeschriebenen Rechnung muss IllegalStateException werfen");
// Rechnung erneut aus Repository laden → unverändert
Rechnung geladen = (Rechnung) repository.findByNummer(rechnung.getDokumentNummer()).get();
assertTrue(geladen.isFestgeschrieben(), "Geladene Rechnung muss noch festgeschrieben sein");
}
// =========================================================================
// MT-DP-13: Preis-Snapshot bleibt stabil (PH-DP-14)
// =========================================================================
@Test
@Order(13)
@DisplayName("MT-DP-13: Spätere Produktpreisänderung beeinflusst bestehende Angebote nicht")
void testPreisSnapshot_BleibtStabil() {
// Angebot mit Preis 100,00 EUR erzeugen
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
Angebot angebot = service.erstelleAngebot(10001L, pos);
BigDecimal preisImAngebot = angebot.getPositionen().get(0).getEinzelpreisNettoSnapshot();
assertEquals(0, new BigDecimal("100.00").compareTo(preisImAngebot),
"Preis im Angebot muss 100,00 EUR sein");
// Produktpreis ändern (Snapshot darf nicht beeinflusst werden)
productLookup.setztePreis(20001L, new BigDecimal("120.00"));
// Angebot erneut aus Repository laden
Angebot geladen = (Angebot) repository.findByNummer(angebot.getDokumentNummer()).get();
BigDecimal preisNachAenderung = geladen.getPositionen().get(0).getEinzelpreisNettoSnapshot();
assertEquals(0, new BigDecimal("100.00").compareTo(preisNachAenderung),
"Snapshot-Preis muss weiterhin 100,00 EUR sein (nicht 120,00)");
}
// =========================================================================
// MT-DP-14: PDF-Export erzeugt Datei (PH-DP-12)
// =========================================================================
@Test
@Order(14)
@DisplayName("MT-DP-14: PDF-Export erzeugt vorhandene, nicht-leere Datei")
void testPdfExport_DateiErzeugt() throws Exception {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
Angebot angebot = service.erstelleAngebot(10001L, pos);
Path pdfPfad = service.exportiereAlsPdf(angebot.getDokumentNummer());
assertNotNull(pdfPfad, "Rückgabepfad darf nicht null sein");
assertTrue(Files.exists(pdfPfad), "PDF-Datei muss existieren: " + pdfPfad);
assertTrue(Files.size(pdfPfad) > 0, "PDF-Datei darf nicht leer sein");
String inhalt = Files.readString(pdfPfad);
assertTrue(inhalt.contains(angebot.getDokumentNummer()),
"PDF muss Dokumentnummer enthalten");
assertTrue(inhalt.contains("ANGEBOT"),
"PDF muss Dokumenttyp enthalten");
}
// =========================================================================
// MT-DP-15: Dokumentübersicht pro Kunde (PH-DP-13)
// =========================================================================
@Test
@Order(15)
@DisplayName("MT-DP-15: findByKunde liefert nur Dokumente des richtigen Kunden")
void testDokumentenuebersicht_ProKunde() {
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
// Dokumente für Kunde 10001
service.erstelleAngebot(10001L, pos);
service.erstelleAngebot(10001L, pos);
// Dokument für Kunde 10002 (anderen Kunden)
service.erstelleAngebot(10002L, pos);
List<Dokument> dokKunde1 = service.findByKunde(10001L);
List<Dokument> dokKunde2 = service.findByKunde(10002L);
assertEquals(2, dokKunde1.size(),
"Kunde 10001 muss genau 2 Dokumente haben");
assertEquals(1, dokKunde2.size(),
"Kunde 10002 muss genau 1 Dokument haben");
assertTrue(dokKunde1.stream().allMatch(d -> d.getKundenId() == 10001L),
"Alle Dokumente in der Liste müssen Kunden-ID 10001 haben");
}
// =========================================================================
// MT-DP-16: Stammdatenreferenz prüfen (PH-DP-15)
// =========================================================================
@Test
@Order(16)
@DisplayName("MT-DP-16: isProductReferenced gibt true/false korrekt zurück")
void testStammdatenreferenz_Pruefen() {
// Angebot mit Produkt 20001 anlegen
List<PositionCommand> pos = List.of(new PositionCommand(20001L, 1));
service.erstelleAngebot(10001L, pos);
// Produkt 20001 wird referenziert → true
assertTrue(repository.existsReferencedProdukt(20001L),
"Produkt 20001 muss als referenziert erkannt werden");
// Produkt 99999 existiert nicht in Dokumenten → false
assertFalse(repository.existsReferencedProdukt(99999L),
"Produkt 99999 darf nicht als referenziert gelten");
// Kunde 10001 wird referenziert → true
assertTrue(repository.existsReferencedKunde(10001L),
"Kunde 10001 muss als referenziert erkannt werden");
// Kunde 99998 nicht → false
assertFalse(repository.existsReferencedKunde(99998L),
"Kunde 99998 darf nicht als referenziert gelten");
}
// =========================================================================
// Hilfsmethode: Erstellt eine vollständige Dokumentenkette bis zum Lieferschein
// =========================================================================
private Lieferschein erstelleKompletteDokumentenkette(long kundenId) {
List<PositionCommand> pos = List.of(
new PositionCommand(20001L, 2),
new PositionCommand(20002L, 1)
);
Angebot angebot = service.erstelleAngebot(kundenId, pos);
Auftragsbestaetigung ab = service.erstelleAuftragsbestaetigung(angebot.getDokumentNummer());
return service.erstelleLieferschein(ab.getDokumentNummer(), LocalDate.now());
}
}

View File

@ -0,0 +1,63 @@
package de.hsmannheim.faktura.product.repository;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import de.hsmannheim.faktura.product.MwstSatz;
import de.hsmannheim.faktura.product.Product;
/**
* Testklasse für das ProductRepository (Gruppe G).
* Überprüft die korrekte Tabelleninitialisierung und die verlustfreie
* dateibasierte Persistenz der Stammdaten (NF-SH-ARCH-01).
*/
public class ProductRepositoryTest {
private ProductRepository productRepository;
@BeforeEach
public void setUp() {
productRepository = new ProductRepository();
for (Product p : productRepository.findAll()) {
productRepository.deleteById(p.getProductId());
}
}
/**
* Testet die tabellarische Bereitstellung aller Produkte für die Übersichtsliste.
* Abdeckung von Anforderung: F-SH-PV-04.
*/
@Test
public void testGetAllAndSorting() {
Product p1 = new Product("P-0001", "A-Schraube", new BigDecimal("1.50"), MwstSatz.NORMAL, "", "");
Product p2 = new Product("P-0002", "B-Mutter", new BigDecimal("0.80"), MwstSatz.NORMAL, "", "");
productRepository.save(p1);
productRepository.save(p2);
List<Product> allProducts = productRepository.findAll();
assertEquals(2, allProducts.size());
}
/**
* Testet die verlustfreie Wiederherstellung der Produktdaten aus dem JSON-Speicher nach einem Systemneustart.
* Abdeckung von Anforderung: NF-SH-ARCH-01.
*/
@Test
public void testPersistenceOnRestart() {
Product product = new Product("P-1000", "Persistenz-Test", new BigDecimal("49.99"), MwstSatz.NORMAL, "Sicherung auf Dateiebene", "Test");
productRepository.save(product);
// Simulation des Anwendungs-Neustarts durch Neuinitialisierung des Repositories
ProductRepository newRepositoryInstance = new ProductRepository();
assertTrue(newRepositoryInstance.findById("P-1000").isPresent());
assertEquals("Persistenz-Test", newRepositoryInstance.findById("P-1000").get().getBezeichnung());
}
}

View File

@ -0,0 +1,63 @@
package de.hsmannheim.faktura.product.service;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import de.hsmannheim.faktura.dokumente.repository.DokumentRepository;
import de.hsmannheim.faktura.product.MwstSatz;
import de.hsmannheim.faktura.product.Product;
import de.hsmannheim.faktura.product.repository.ProductRepository;
/**
* Performancetest-Klasse für die Produktverwaltung (Gruppe G).
* Verifiziert die Zeitschranken beim Laden und Filtern von großen Datenmengen (NF-SH-PERF-01).
*/
@SuppressWarnings("all")
public class ProductPerformanceTest {
/**
* Überprüft, ob das Durchsuchen und Echtzeit-Filtern bei einem Datenbestand von 1.000
* Produkten in weniger als 2 Sekunden vollständig abgeschlossen wird.
* Abdeckung von Anforderung: NF-SH-PERF-01.
*/
@Test
public void testSearchPerformance() {
ProductRepository repo = new ProductRepository();
DokumentRepository dummyDocRepo = new de.hsmannheim.faktura.dokumente.repository.DokumentRepository() {
@Override public boolean existsReferencedProdukt(long id) { return false; }
@Override public boolean existsReferencedKunde(long id) { return false; }
@Override public int findMaxLaufendeNummer(String p, int j) { return 0; }
@Override public void save(de.hsmannheim.faktura.dokumente.domain.Dokument d) {}
@Override public java.util.Optional<de.hsmannheim.faktura.dokumente.domain.Dokument> findByNummer(String n) { return java.util.Optional.empty(); }
@Override public List<de.hsmannheim.faktura.dokumente.domain.Dokument> findByKunde(long id) { return java.util.List.of(); }
@Override public boolean existsFolgedokument(String q) { return false; }
@Override public List<de.hsmannheim.faktura.dokumente.domain.Dokument> findAll() { return java.util.List.of(); }
@Override public boolean delete(String n) { return false; }
};
ProductService service = new ProductService(repo, dummyDocRepo);
// Bereinigen und künstliches Befüllen mit exakt 1.000 Produkten
for (Product p : repo.findAll()) {
repo.deleteById(p.getProductId());
}
for (int i = 1; i <= 1000; i++) {
repo.save(new Product("P-" + i, "Produkt Nummer " + i, new BigDecimal("10.00"), MwstSatz.NORMAL, "", "Kategorie"));
}
// Zeitmessung starten
long startTime = System.currentTimeMillis();
List<Product> results = service.searchProducts("Nummer 999");
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
// Überprüfung der Zeitschranke (2000 Millisekunden = 2 Sekunden)
assertTrue(duration < 2000, "Der Lasttest wurde nicht in der geforderten Zeit von 2 Sekunden erfüllt. Benötigt: " + duration + "ms");
}
}

View File

@ -0,0 +1,235 @@
package de.hsmannheim.faktura.product.service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import de.hsmannheim.faktura.dokumente.domain.Dokument;
import de.hsmannheim.faktura.product.MwstSatz;
import de.hsmannheim.faktura.product.Product;
import de.hsmannheim.faktura.product.repository.ProductRepository;
/**
* Komponententest-Klasse für den ProductService (Gruppe G).
* Überprüft die Einhaltung der funktionalen Anforderungen und Geschäftsregeln
* aus dem Pflichtenheft vollständig ohne GUI-Abhängigkeiten (NF-TEST-02).
*/
@SuppressWarnings("all")
public class ProductServiceTest {
private ProductService productService;
private ProductRepository productRepository;
private de.hsmannheim.faktura.dokumente.repository.DokumentRepository dokumentRepository;
@BeforeEach
public void setUp() {
productRepository = new ProductRepository();
dokumentRepository = new de.hsmannheim.faktura.dokumente.repository.DokumentRepository() {
@Override
public boolean existsReferencedProdukt(long produktId) {
return false;
}
@Override
public boolean existsReferencedKunde(long kundenId) {
return false;
}
@Override
public int findMaxLaufendeNummer(String praefix, int jahr) {
return 0;
}
@Override
public void save(Dokument dokument) {
throw new UnsupportedOperationException("Unimplemented method 'save'");
}
@Override
public Optional<Dokument> findByNummer(String nummer) {
throw new UnsupportedOperationException("Unimplemented method 'findByNummer'");
}
@Override
public List<Dokument> findByKunde(long kundenId) {
throw new UnsupportedOperationException("Unimplemented method 'findByKunde'");
}
@Override
public boolean existsFolgedokument(String quellDokumentNummer) {
throw new UnsupportedOperationException("Unimplemented method 'existsFolgedokument'");
}
@Override
public List<Dokument> findAll() {
throw new UnsupportedOperationException("Unimplemented method 'findAll'");
}
@Override
public boolean delete(String nummer) {
throw new UnsupportedOperationException("Unimplemented method 'delete'");
}
};
productService = new ProductService(productRepository, dokumentRepository);
for (Product p : productRepository.findAll()) {
productRepository.deleteById(p.getProductId());
}
}
/**
* Testet das erfolgreiche Anlegen eines validen Produkts mit allen Pflichtattributen.
* Abdeckung von Anforderung: F-SH-PV-01.
*/
@Test
public void testSaveValidProduct() {
Product valid = new Product("P-0001", "M6 Schraube", new BigDecimal("10.50"), MwstSatz.NORMAL, "Beschreibung", "Werkzeug");
assertDoesNotThrow(() -> productService.saveProduct(valid));
assertTrue(productService.getProductById("P-0001").isPresent());
}
/**
* Testet das erfolgreiche Modifizieren und Überschreiben bestehender Produktdatensätze.
* Abdeckung von Anforderung: F-SH-PV-02.
*/
@Test
public void testUpdateProductAttributes() {
Product product = new Product("P-0001", "Alte Bezeichnung", new BigDecimal("5.00"), MwstSatz.NORMAL, "Alt", "Alt");
productService.saveProduct(product);
Product updatedProduct = new Product("P-0001", "Neue Bezeichnung", new BigDecimal("12.50"), MwstSatz.ERMAESSIGT, "Neu", "Neu");
productService.saveProduct(updatedProduct);
Optional<Product> result = productService.getProductById("P-0001");
assertTrue(result.isPresent());
assertEquals("Neue Bezeichnung", result.get().getBezeichnung());
assertEquals(new BigDecimal("12.50"), result.get().getEinzelpreisNetto());
assertEquals(MwstSatz.ERMAESSIGT, result.get().getMwstSatz());
}
/**
* Prüft, ob Preise kleiner als 0.00 wie gefordert blockiert und mit einer Exception abgewiesen werden.
* Abdeckung von Anforderung: F-SH-PV-06 / AC-SH-PV-01.
*/
@Test
public void testNegativePriceRejection() {
Product invalidProduct = new Product("P-9999", "Testprodukt", new BigDecimal("-1.00"), MwstSatz.NORMAL, "Test", "Test");
assertThrows(IllegalArgumentException.class, () -> {
productService.saveProduct(invalidProduct);
});
}
/**
* Überprüft, ob Beschreibungen mit mehr als 500 Zeichen blockiert und mit einer Exception abgewiesen werden.
* Abdeckung von Anforderung: F-PV-07.
*/
@Test
public void testDescriptionLengthBounds() {
StringBuilder longDescription = new StringBuilder();
for (int i = 0; i < 501; i++) {
longDescription.append("A");
}
Product invalidProduct = new Product("P-0002", "Produkt mit langer Beschreibung", new BigDecimal("10.00"), MwstSatz.NORMAL, longDescription.toString(), "Test");
assertThrows(IllegalArgumentException.class, () -> {
productService.saveProduct(invalidProduct);
});
}
/**
* Prüft, ob die optionale Kategoriezuweisung korrekt verarbeitet und persistent im Objekt hinterlegt wird.
* Abdeckung von Anforderung: F-PV-08.
*/
@Test
public void testCategoryAssignment() {
Product product = new Product("P-0003", "Bohrmaschine", new BigDecimal("89.99"), MwstSatz.NORMAL, "Schlagbohrer", "Elektrowerkzeuge");
productService.saveProduct(product);
Optional<Product> result = productService.getProductById("P-0003");
assertTrue(result.isPresent());
assertEquals("Elektrowerkzeuge", result.get().getKategorie());
}
/**
* Überprüft die Echtzeit-Filterung der Produktliste anhand einer Teilmenge des Namens,
* wobei Groß- und Kleinschreibung ignoriert werden muss.
* Abdeckung von Anforderung: F-SH-PV-05 / AC-SH-PV-03.
*/
@Test
public void testSearchCaseInsensitive() {
Product p1 = new Product("P-0001", "M6 Schraube", new BigDecimal("1.00"), MwstSatz.NORMAL, "", "");
Product p2 = new Product("P-0002", "M8 SCHRAUBE", new BigDecimal("2.00"), MwstSatz.NORMAL, "", "");
productService.saveProduct(p1);
productService.saveProduct(p2);
List<Product> results = productService.searchProducts("schraube");
assertEquals(2, results.size());
}
/**
* Überprüft die Blockierung des Löschvorgangs, falls das Produkt in Belegen referenziert wird.
* Abdeckung von Geschäftsregel: GR-05 / AC-SH-PV-02 / F-SH-PV-03.
*/
@Test
public void testDeleteReferencedProduct() {
de.hsmannheim.faktura.dokumente.repository.DokumentRepository mockRepo = new de.hsmannheim.faktura.dokumente.repository.DokumentRepository() {
@Override
public boolean existsReferencedProdukt(long produktId) {
return true;
}
@Override
public boolean existsReferencedKunde(long kundenId) {
return false;
}
@Override
public int findMaxLaufendeNummer(String praefix, int jahr) {
return 0;
}
@Override
public void save(Dokument dokument) {
throw new UnsupportedOperationException("Unimplemented method 'save'");
}
@Override
public Optional<Dokument> findByNummer(String nummer) {
throw new UnsupportedOperationException("Unimplemented method 'findByNummer'");
}
@Override
public List<Dokument> findByKunde(long kundenId) {
throw new UnsupportedOperationException("Unimplemented method 'findByKunde'");
}
@Override
public boolean existsFolgedokument(String quellDokumentNummer) {
throw new UnsupportedOperationException("Unimplemented method 'existsFolgedokument'");
}
@Override
public List<Dokument> findAll() {
throw new UnsupportedOperationException("Unimplemented method 'findAll'");
}
@Override
public boolean delete(String nummer) {
throw new UnsupportedOperationException("Unimplemented method 'delete'");
}
};
ProductService serviceWithSperre = new ProductService(productRepository, mockRepo);
Product product = new Product("P-0001", "Gesperrtes Produkt", new BigDecimal("10.00"), MwstSatz.NORMAL, "", "");
productRepository.save(product);
boolean result = serviceWithSperre.deleteProduct("P-0001");
assertFalse(result, "Das Löschen hätte blockiert werden müssen, da das Produkt referenziert wird.");
}
}