Pflichtenheft + Maven Build

main
Lucas Strubel 2026-06-10 17:22:08 +02:00
parent 37eee25bd6
commit a4fc341341
77 changed files with 7476 additions and 0 deletions

10
.gitignore vendored 100644
View File

@ -0,0 +1,10 @@
# Maven-Build
target/
# Lokale Laufzeitdaten der Anwendung (IF-01, Q-06)
daten/
# IDE
.idea/
.vscode/
*.iml

View File

@ -0,0 +1,560 @@
---
title: "Pflichtenheft"
subtitle: "Desktop-Fakturierungsanwendung — Gruppe A: Prozess / Dokumentenzyklus"
author:
- Team 1 Gruppe A
version: "1.0"
lang: de-DE
toc: true
toc-depth: 3
numbersections: false
papersize: a4
geometry: "margin=3cm"
fontsize: 12pt
linestretch: 1.5
mainfont: "Times New Roman"
sansfont: "Arial"
monofont: "DejaVu Sans Mono"
header-includes: |
\usepackage{fancyhdr}
\usepackage{lastpage}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{Team 1 Gruppe A}
\fancyhead[C]{Pflichtenheft}
\fancyhead[R]{Version 1.0}
\fancyfoot[C]{\thepage\ /\ \pageref{LastPage}}
\renewcommand{\headrulewidth}{0.4pt}
\renewcommand{\footrulewidth}{0pt}
---
\newpage
+-------------------------+-------------------------+-------------------------+
| Autor | Prüfer | Freigebender |
+=========================+=========================+=========================+
| Strubel, Lucas | Prof. Dr. Marmitt, Gerd | Prof. Dr. Marmitt, Gerd |
| Kaiser, Luca | | |
+-------------------------+-------------------------+-------------------------+
| Gruppe A (Prozess) | Modulverantwortlicher | Modulverantwortlicher |
+-------------------------+-------------------------+-------------------------+
| 09.06.2026 | 09.06.2026 | 09.06.2026 |
+-------------------------+-------------------------+-------------------------+
**Freigabevermerk:** Dieses Dokument ist nach Prüfung und Freigabe durch den
Modulverantwortlichen verbindliche Spezifikationsgrundlage für die Implementierung
und den Modultest der Komponente *Prozess / Dokumentenzyklus*.
## Dokumentenhistorie
| Version | Datum | Autor | Grund der Änderung |
|---------|------------|-----------------------------|---------------------|
| 1.0 | 09.06.2026 | Lucas Strubel, Luca Kaiser | Initiale Erstellung |
\newpage
## 1. Einleitung
### 1.1 Zweck des Dokuments
Dieses Pflichtenheft (System Requirements Specification, SRS) beschreibt aus Sicht des
Auftragnehmers, **wie** die Komponente *Prozess / Dokumentenzyklus* der
Desktop-Fakturierungsanwendung die Anforderungen des Lastenhefts (v1.3) erfüllt. Es
konkretisiert die fachlichen Anforderungen in testbare Systemanforderungen und dient als
direkte Grundlage für Design, Implementierung sowie den Komponenten- bzw. Modultestplan
(Kapitel 10).
### 1.2 Ziel
Ziel dieses Pflichtenhefts ist die vollständige und testbare Spezifikation der Erzeugung,
Verknüpfung und Statusführung der vier kaufmännischen Belegtypen (Angebot,
Auftragsbestätigung, Lieferschein, Rechnung), der geführten Rechnungserstellung sowie der
Rechnungsstornierung.
### 1.3 Geltungsbereich
Dieses Dokument gilt für die Komponente **Gruppe A — Prozess / Dokumentenzyklus**. Die
Gesamtanwendung wird arbeitsteilig in vier Komponenten entwickelt; jede Untergruppe pflegt
ein eigenes Pflichtenheft:
| Gruppe | Komponente | Eigenes Pflichtenheft |
|--------|-------------------------|-----------------------|
| A | Prozess / Dokumentenzyklus | **dieses Dokument** |
| B | Verwaltung von Produkten | separat |
| C | Verwaltung von Kunden | separat |
| D | Programmoberfläche | separat |
Die Komponente A **nutzt** Kunden (Gruppe C) und Produkte (Gruppe B) lesend über definierte
interne Schnittstellen (Kapitel 6.2) und wird über die Programmoberfläche (Gruppe D)
bedient. Stammdatenpflege (Anlegen/Ändern/Löschen von Kunden und Produkten) ist **nicht**
Gegenstand dieses Dokuments.
### 1.4 Definitionen und Abkürzungen
Fachbegriffe (Angebot, Auftragsbestätigung, Lieferschein, Rechnung, Dokumentenzyklus,
GoBD, DSGVO, …) sind im Glossar des Lastenhefts (§ 8.1) definiert und gelten unverändert.
Dokumentspezifische Abkürzungen siehe Kapitel 11.
### 1.5 Referenzen
- Lastenheft „Desktop-Fakturierungsanwendung", Team 1, Version 1.3, 09.06.2026
- Project Charter, Team 1, Version 1.3, 14.05.2026
- § 14 UStG — Pflichtangaben einer Rechnung
- GoBD — Grundsätze zur ordnungsmäßigen Führung und Aufbewahrung von Büchern
- DSGVO — EU-Verordnung 2016/679
- Vorlesungsunterlagen Software Engineering 1 (SoSe 2026), Foliensatz „Lasten- und Pflichtenheft"
---
## 2. Systemüberblick
### 2.1 Kurzbeschreibung
Die Anwendung ist eine **Einzelplatz-Stand-Alone-Desktop-Anwendung** mit **lokaler
Datenhaltung** (keine Cloud, kein Server). Die Bedienung erfolgt über eine **minimale
grafische Benutzeroberfläche** (Gruppe D), über die die Funktionalität des
Dokumentenzyklus zugänglich gemacht wird. Erzeugte Belege werden lokal als **PDF**
exportiert und können optional gedruckt oder per Standard-E-Mail-Client versendet werden.
Die Komponente *Prozess / Dokumentenzyklus* stellt die fachliche Kernlogik bereit:
Belegerzeugung, automatische Summen- und Steuerberechnung, Vergabe eindeutiger
Belegnummern, Verknüpfung aufeinanderfolgender Belege, Statusführung und Stornierung.
### 2.2 Abgrenzung (Was gehört dazu / was nicht)
**Im Umfang dieser Komponente:**
- Erstellen von Angebot, Auftragsbestätigung, Lieferschein, Rechnung
- Übernahme von Daten aus Vorgängerbelegen (Dokumentenzyklus-Konsistenz)
- Automatische Berechnung von Netto-, Steuer- und Bruttobeträgen (Snapshot-Prinzip)
- Vergabe fortlaufender, lückenloser Belegnummern
- Geführte (schrittweise) Rechnungserstellung
- Statusführung und Stornierung von Rechnungen
- PDF-Export der Belege
**Nicht im Umfang dieser Komponente:**
- Anlegen/Ändern/Löschen von Kunden (Gruppe C) und Produkten (Gruppe B)
- Aufbau und Layout der GUI (Gruppe D)
- E-Rechnungsformate (ZUGFeRD/XRechnung), Mahnwesen, Buchhaltung (LH-Nichtziele)
### 2.3 Grobe Systemfunktionen
Erstellen von Belegen → Berechnen der Summen → Vergeben der Belegnummer → Persistieren →
PDF-Export → Statuswechsel (inkl. Storno).
### 2.4 UML-Bezug
Ein gemeinsames Use-Case-Diagramm aller Gruppen gibt den Überblick über die Akteure und
Ziele. Die für Gruppe A relevanten Use Cases sind: *Angebot erstellen*,
*Auftragsbestätigung erstellen*, *Lieferschein erstellen*, *Rechnung erstellen (geführt)*
und *Rechnung stornieren*. Die detaillierte logische Architektur dieser Komponente folgt
in Kapitel 7.
---
## 3. Stakeholder und Kontext
Stakeholder und Systemkontext sind im Lastenheft (§ 2, § 3) beschrieben und gelten
unverändert. Für diese Komponente ist der maßgebliche Akteur:
- **Anwender:in** — natürliche Person (Selbstständige:r, Freiberufler:in,
Kleinstunternehmer:in), die den Dokumentenzyklus eigenverantwortlich durchführt.
Angrenzende Systeme/Komponenten: lokales Dateisystem (Persistenz, PDF), optional Drucker
und Standard-E-Mail-Client sowie intern die Komponenten Kundenverwaltung (C) und
Produktverwaltung (B).
---
## 4. Funktionale Anforderungen
Die Anforderungen sind nach Belegtyp gruppiert und mit den Satzschablonen des Foliensatzes
formuliert. Jede Anforderung ist eindeutig, vollständig, widerspruchsfrei und verifizierbar.
> **Belegnummern (übergreifend, GR-01):** Belegnummern sind **eindeutig** und werden
> **vom System generiert** (nicht durch den Anwender eingegeben). Sie werden als
> `String` geführt, **nicht** als `int`, weil die Nummern ein festes Format mit
> führenden Nullen und Präfix besitzen (z. B. `R-2026-000124`); ein ganzzahliger Typ
> würde führende Nullen verlieren. Pro Belegtyp wird ein eigener, fortlaufender und
> **lückenloser** Zähler auf Basis der höchsten bisher vergebenen Nummer geführt.
> Präfixe: `AN-` (Angebot), `AB-` (Auftragsbestätigung), `LS-` (Lieferschein),
> `R-` (Rechnung).
### 4.1 Angebot (aus BA-09)
**F-01:** Das System MUSS es der Anwender:in ERMÖGLICHEN, ein Angebot für einen
existierenden Kunden mit mindestens einer Position aus dem Produktkatalog zu erstellen.
**F-02:** WENN ein Angebot gespeichert wird, DANN MUSS das System eine eindeutige
Angebotsnummer (Präfix `AN-`), das Erstelldatum und ein Gültigkeitsdatum setzen.
**F-03:** Das System MUSS für jedes Angebot die Netto-, Steuer- und Bruttosumme automatisch
aus den Positionen berechnen (siehe F-23).
**F-04:** Das System MUSS es ERMÖGLICHEN, ein gespeichertes Angebot als PDF in das lokale
Dateisystem zu exportieren.
### 4.2 Auftragsbestätigung (aus BA-10)
**F-05:** Das System MUSS es ERMÖGLICHEN, eine Auftragsbestätigung zu erstellen, wobei
Kunde, Positionen und Mengen aus einem vorhandenen Angebot übernommen werden können
(siehe F-22).
**F-06:** WENN eine Auftragsbestätigung gespeichert wird, DANN MUSS das System eine
eindeutige AB-Nummer (Präfix `AB-`) vergeben und — sofern aus einem Angebot erzeugt — eine
Rückreferenz auf das Angebot speichern.
**F-07:** Das System MUSS es ERMÖGLICHEN, eine Auftragsbestätigung als PDF zu exportieren.
### 4.3 Lieferschein (aus BA-11)
**F-08:** Das System MUSS es ERMÖGLICHEN, einen Lieferschein mit Lieferdatum, Positionen
und Liefermengen zu erstellen; Kunde und Positionen können aus einer Auftragsbestätigung
übernommen werden (siehe F-22).
**F-09:** WENN ein Lieferschein gespeichert wird, DANN MUSS das System eine eindeutige
Lieferscheinnummer (Präfix `LS-`) vergeben und — sofern aus einer Auftragsbestätigung
erzeugt — eine Rückreferenz speichern.
**F-10:** Das System MUSS es ERMÖGLICHEN, einen Lieferschein als PDF zu exportieren.
### 4.4 Rechnung (aus BA-12)
**F-11:** Das System MUSS es ERMÖGLICHEN, eine Rechnung für einen Kunden mit mindestens
einer Position zu erstellen.
**F-12:** WENN eine Rechnung gespeichert wird, DANN MUSS das System eine fortlaufende,
lückenlose Rechnungsnummer (Präfix `R-`) auf Basis der höchsten bisher vergebenen Nummer
vergeben (GR-01).
**F-13:** Das System MUSS in jeder Rechnung die Pflichtangaben gemäß § 14 UStG führen:
Rechnungsnummer, Rechnungsdatum, Leistungsdatum, Kundendaten, Positionen mit Einzel- und
Gesamtbeträgen, Steuersatz und Steuerbetrag sowie Netto-, Steuer- und Bruttosumme.
**F-14:** WENN bei der Rechnungserstellung kein abweichendes Zahlungsziel angegeben ist,
DANN MUSS das System ein Standard-Zahlungsziel von **14 Kalendertagen** ab Rechnungsdatum
setzen (GR-06).
**F-15:** Das System MUSS es ERMÖGLICHEN, eine Rechnung als PDF zu exportieren.
### 4.5 Geführte Rechnungserstellung (aus BA-13)
**F-16:** Das System MUSS es ERMÖGLICHEN, die Rechnungserstellung schrittweise
durchzuführen: (1) Kunde auswählen, (2) mindestens eine Produktposition mit Menge erfassen,
(3) Rechnungsdatum und Zahlungsziel bestätigen, (4) Zusammenfassung prüfen, (5) speichern.
**F-17:** WENN die Schritteingabe abgeschlossen ist, DANN MUSS das System vor dem Speichern
eine **Zusammenfassung** mit Kunde, allen Positionen, Mengen, Netto-/Steuer-/Bruttosumme,
Rechnungsdatum und Zahlungsziel anzeigen.
**F-18:** WENN ein Pflichtfeld fehlt (kein Kunde, keine Position, kein Rechnungsdatum),
DANN MUSS das System das Speichern ablehnen und das fehlende Pflichtfeld benennen (GR/Q-09).
### 4.6 Rechnung stornieren (aus BA-14)
**F-19:** Das System MUSS es ERMÖGLICHEN, eine gespeicherte Rechnung im Status `OFFEN` zu
stornieren.
**F-20:** WENN eine Rechnung storniert wird, DANN MUSS das System ihren Status auf
`STORNIERT` setzen, sie nicht mehr in der Liste offener Rechnungen führen und den Vorgang
mit Datum protokollieren.
**F-21:** WENN eine Rechnung den Status `STORNIERT` hat, DANN MUSS das System jede weitere
inhaltliche Änderung ablehnen.
### 4.7 Übergreifende Prozessregeln
**F-22 (Dokumentenzyklus-Konsistenz, GR-05):** WENN ein Beleg aus einem Vorgängerbeleg
erzeugt wird, DANN MUSS das System Kunde, Positionen und Mengen übernehmen und eine
eindeutige Rückreferenz auf den Vorgänger speichern.
**F-23 (Steuer-/Summenberechnung, GR-03):** WENN eine Position gespeichert wird, DANN MUSS
das System Netto-, Steuer- und Bruttobetrag automatisch berechnen, wobei der zum Zeitpunkt
der Belegerstellung gültige Steuersatz und Einzelpreis des Produkts als unveränderlicher
**Snapshot** in der Position abgelegt werden.
**F-24 (Unveränderlichkeit, GR-02 / Q-07):** WENN ein Beleg den Status `VERSENDET` hat,
DANN MUSS das System jede inhaltliche Änderung ablehnen; Korrekturen erfolgen ausschließlich
über neue Belege (Storno-/Korrekturrechnung).
---
## 5. Nicht-funktionale Anforderungen
**NF-PERF-01 (aus Q-03):** Das System MUSS die Erstellung und den PDF-Export eines beliebigen
Belegtyps INNERHALB VON 2 SEKUNDEN abschließen, bei Belegen mit bis zu 50 Positionen und
einem Datenbestand gemäß Q-01 (bis 5.000 Kunden/Produkte).
**NF-INT-01 (aus Q-07 / GR-02):** Das System MUSS nach dem Status `VERSENDET` einer Rechnung
**jede** inhaltliche Änderung ablehnen; zulässig bleiben ausschließlich Storno- bzw.
Korrekturvorgänge über neue Belege.
**NF-USE-01 (aus Q-05):** Die geführte Erstellung einer vollständigen Rechnung an einen
bestehenden Kunden MUSS von einer erstmaligen Anwender:in OHNE EXTERNE HILFE IN WENIGER ALS
10 MINUTEN im ersten Versuch abgeschlossen werden können (Nachweis durch Usability-Test mit
mind. 5 Testpersonen).
**NF-USE-02 (aus Q-09):** Das System MUSS fehlende Pflichtangaben in den Belegformularen so
markieren und benennen, dass mindestens 80 % der Testpersonen die fehlende Eingabe ohne
externe Hilfe im ersten Korrekturversuch ergänzen können.
---
## 6. Daten und Schnittstellen
Dieses Kapitel ist direkter Input für den Modultestplan (Kapitel 10). Datentypen werden
bereits als Java-Typen angegeben.
### 6.1 Datenobjekte und Datentypen
**Designgrundsätze:**
- **Geldbeträge** werden als `java.math.BigDecimal` mit **Scale 2** und kaufmännischer
Rundung (`RoundingMode.HALF_UP`) geführt — **nicht** als `double` (Gleitkomma-Rundungs­fehler
wären für Beträge unzulässig).
- **Belegnummern** werden als `String` geführt (festes Format mit Präfix und führenden
Nullen, z. B. `"R-2026-000124"`) — **nicht** als `int`.
- **Datumswerte** werden als `java.time.LocalDate` geführt.
- **Mengen** werden als `int` (Stückzahl) geführt.
- **Steuersätze** werden als `BigDecimal` als Faktor geführt (z. B. `0.19`).
#### `enum DokumentStatus`
`{ ENTWURF, OFFEN, VERSENDET, STORNIERT }`
#### Klasse `Dokumentposition`
| Attribut | Java-Typ | Beschreibung |
|-------------------|---------------|--------------|
| produktReferenz | `String` | Produktnummer des referenzierten Produkts (Gruppe B) |
| bezeichnung | `String` | Snapshot der Produktbezeichnung zum Erstellzeitpunkt |
| menge | `int` | Stückzahl (> 0) |
| einzelpreisNetto | `BigDecimal` | Snapshot Netto-Einzelpreis (Scale 2) |
| steuersatz | `BigDecimal` | Snapshot Steuersatz, z. B. `0.19` |
| positionssummeNetto | `BigDecimal` | `einzelpreisNetto * menge` (Scale 2) |
#### Abstrakte Klasse `Dokument`
| Attribut | Java-Typ | Beschreibung |
|----------------|----------------------------|--------------|
| belegnummer | `String` | eindeutig, vom System generiert |
| datum | `LocalDate` | Erstelldatum |
| kundenReferenz | `String` | Kundennummer (Gruppe C) |
| positionen | `List<Dokumentposition>` | mind. 1 Position |
| status | `DokumentStatus` | Lebenszyklus-Status |
| vorgaengerNr | `String` (optional, `null`)| Rückreferenz auf Vorgängerbeleg (GR-05) |
| summeNetto | `BigDecimal` | Summe aller Positionssummen (Scale 2) |
| summeSteuer | `BigDecimal` | Summe der Steuerbeträge (Scale 2) |
| summeBrutto | `BigDecimal` | `summeNetto + summeSteuer` (Scale 2) |
#### Spezialisierungen (erben von `Dokument`)
| Klasse | Zusätzliche Attribute (Java-Typ) |
|------------------------|----------------------------------|
| `Angebot` | `gueltigBis: LocalDate` |
| `Auftragsbestaetigung` | — (nutzt `vorgaengerNr` → Angebot) |
| `Lieferschein` | `lieferdatum: LocalDate` |
| `Rechnung` | `leistungsdatum: LocalDate`, `zahlungsziel: LocalDate`, `storniertAm: LocalDate` (optional) |
### 6.2 Schnittstellen
**Externe Schnittstellen:**
| ID | Schnittstelle | Zweck |
|-------|---------------------------|-------|
| IF-01 | Lokales Dateisystem | Persistenz der Belege, Ablage exportierter PDF-Dokumente |
| IF-02 | Druckersystem (optional) | Direkter Druck eines Belegs |
| IF-03 | Standard-E-Mail-Client (optional) | Versand eines Belegs als PDF-Anhang |
**Interne Schnittstellen (zu anderen Komponenten), als Java-Interfaces skizziert:**
```java
// Lesender Zugriff auf Kundenstammdaten (Gruppe C)
public interface KundenService {
Kunde findeKunde(String kundennummer); // null, wenn nicht vorhanden
}
// Lesender Zugriff auf Produktstammdaten (Gruppe B)
public interface ProduktService {
Produkt findeProdukt(String produktnummer);
}
// PDF-Export eines Belegs (IF-01)
public interface PdfExporter {
void exportiere(Dokument dokument, Path zielDatei);
}
```
**Belegnummern-Schnittstelle (komponenteninterner Dienst):**
```java
public interface BelegnummernGenerator {
// liefert die nächste lückenlose Nummer für den Belegtyp (GR-01)
String naechsteNummer(Belegtyp typ, int jahr);
}
```
> IF-Satzschablone (Beispiel IF-01): *Das System MUSS eine Datei-Schnittstelle
> bereitstellen, die es dem lokalen Dateisystem ERMÖGLICHT, Belege zu persistieren und PDF-
> Dokumente abzulegen. Die Schnittstelle MUSS gängige Dateipfade (`java.nio.file.Path`)
> verwenden.*
---
## 7. Systemarchitektur (logisch, grob)
Die Komponente folgt einer einfachen Schichtung: die GUI (Gruppe D) ruft den
`DokumentService` auf, der die Fachlogik kapselt und die Dienste `BelegnummernGenerator`,
`KundenService`, `ProduktService` und `PdfExporter` nutzt. Belege werden über ein
`DokumentRepository` im lokalen Dateisystem persistiert.
### 7.1 Klassendiagramm
<!-- TODO: UML-Klassendiagramm hier einfügen (Abbildung 1) -->
![Abbildung 1: UML-Klassendiagramm Dokumentenzyklus (Gruppe A)]
**Beschreibung zu Abbildung 1:** Das Klassendiagramm zeigt die abstrakte Oberklasse
`Dokument` mit den Spezialisierungen `Angebot`, `Auftragsbestaetigung`, `Lieferschein` und
`Rechnung` (Vererbung). Ein `Dokument` besteht aus einer bis vielen `Dokumentposition`-
Objekten (Komposition `Dokument` ◆—— `Dokumentposition`, Multiplizität `1..*`). Jede
`Dokumentposition` referenziert ein `Produkt` (Gruppe B), ein `Dokument` referenziert einen
`Kunde` (Gruppe C) — jeweils über die Stammdatennummer (lose Kopplung). Der `DokumentService`
orchestriert die Erstellung und nutzt den `BelegnummernGenerator` (Vergabe lückenloser
Belegnummern), die Schnittstellen `KundenService`/`ProduktService` (Stammdaten) sowie den
`PdfExporter` (PDF-Export). Der Status eines Belegs wird über das Enum `DokumentStatus`
abgebildet.
### 7.2 Sequenzdiagramm
<!-- TODO: UML-Sequenzdiagramm hier einfügen (Abbildung 2) -->
![Abbildung 2: UML-Sequenzdiagramm „Rechnung erstellen" (Gruppe A)]
**Beschreibung zu Abbildung 2:** Das Sequenzdiagramm stellt den Ablauf *Rechnung erstellen*
dar. Die Anwender:in löst über die GUI (Gruppe D) `erstelleRechnung(kundenNr, positionen)`
am `DokumentService` aus. Dieser ermittelt über `KundenService.findeKunde(...)` und
`ProduktService.findeProdukt(...)` die Stammdaten (Snapshot), berechnet je Position und in
Summe Netto-, Steuer- und Bruttobetrag (F-23), fordert vom `BelegnummernGenerator` mit
`naechsteNummer(RECHNUNG, jahr)` eine lückenlose Rechnungsnummer an (GR-01), setzt das
Standard-Zahlungsziel (+14 Tage, GR-06), persistiert den Beleg über das `DokumentRepository`
und exportiert ihn über `PdfExporter.exportiere(...)`. Abschließend wird die gespeicherte
`Rechnung` an die GUI zurückgegeben.
---
## 8. Testbare Abnahmekriterien
**AC-A-01 (zu F-01F-04, NF-PERF-01)** — *Angebot erstellen und exportieren*
Vorbedingung: Ein Kunde und 5 Produkte sind erfasst.
Aktion: Anwender:in erstellt ein Angebot mit 5 Positionen und exportiert es als PDF.
Erwartet: Das Angebot ist mit Angebotsnummer (`AN-…`) und korrekten Summen gespeichert; der
PDF-Export ist in ≤ 2 Sekunden abgeschlossen.
**AC-A-02 (zu F-05F-07, F-22)** — *Auftragsbestätigung aus Angebot*
Vorbedingung: Ein Angebot liegt vor.
Aktion: Anwender:in erstellt eine Auftragsbestätigung mit Übernahme aller Positionen.
Erwartet: Die AB ist mit eindeutiger Nummer (`AB-…`), übernommenen Positionen/Mengen und
Rückreferenz auf das Angebot gespeichert und als PDF exportierbar.
**AC-A-03 (zu F-08F-10, F-22)** — *Lieferschein erstellen*
Vorbedingung: Eine Auftragsbestätigung liegt vor.
Aktion: Anwender:in erstellt einen Lieferschein mit Lieferdatum.
Erwartet: Der Lieferschein ist mit eindeutiger Nummer (`LS-…`), Lieferdatum und allen
Positionsdaten gespeichert und als PDF exportierbar.
**AC-A-04 (zu F-11F-15, F-23)** — *Rechnung mit Pflichtangaben und Standard-Zahlungsziel*
Vorbedingung: Kunde und mind. eine Position liegen vor; letzte Rechnungsnummer = `R-2026-000123`.
Aktion: Anwender:in erstellt eine Rechnung mit Rechnungsdatum 09.06.2026 ohne abweichendes
Zahlungsziel.
Erwartet: Die Rechnung trägt die Nummer `R-2026-000124`, ein Zahlungsziel 23.06.2026
(+14 Tage), alle Pflichtangaben gem. § 14 UStG sowie korrekte Netto-/Steuer-/Bruttosummen.
**AC-A-05 (zu F-16F-18, NF-USE-01/02)** — *Geführte Rechnungserstellung*
Vorbedingung: Mind. ein Kunde und ein Produkt vorhanden.
Aktion: Anwender:in durchläuft die geführte Erstellung (Kunde → Position+Menge → Datum/
Zahlungsziel → Zusammenfassung → speichern).
Erwartet: Vor dem Speichern erscheint eine Zusammenfassung mit Kunde, Position, Menge,
Summen, Rechnungsdatum und Zahlungsziel; fehlt ein Pflichtfeld, wird das Speichern abgelehnt
und das fehlende Feld benannt.
**AC-A-06 (zu F-19F-21)** — *Rechnung stornieren*
Vorbedingung: Eine Rechnung im Status `OFFEN` existiert.
Aktion: Anwender:in storniert die Rechnung.
Erwartet: Status wird `STORNIERT`, die Rechnung erscheint nicht mehr in der Liste offener
Rechnungen, der Vorgang ist mit Datum protokolliert; weitere Änderungen werden abgelehnt.
**AC-A-07 (zu F-23, F-24, NF-INT-01)** — *Snapshot und Unveränderlichkeit*
Vorbedingung: Eine Rechnung mit einem Produkt ist erstellt; danach wird der Produktpreis
geändert; eine zweite Rechnung im Status `VERSENDET` existiert.
Aktion: Vergleich der ersten Rechnung mit dem geänderten Produktpreis; Änderungsversuch an
der versendeten Rechnung.
Erwartet: Die erste Rechnung behält den ursprünglichen Preis (Snapshot); der Änderungsversuch
an der versendeten Rechnung wird abgelehnt.
---
## 9. Traceability LH ↔ PH
Jede für Gruppe A relevante Lastenheft-Anforderung ist mindestens einer
Pflichtenheft-Anforderung zugeordnet.
| LH-Anforderung | Beschreibung (LH) | PH-Anforderung(en) |
|----------------|-------------------------------------------|---------------------------|
| BA-09 | Angebot erstellen | F-01, F-02, F-03, F-04 |
| BA-10 | Auftragsbestätigung erstellen | F-05, F-06, F-07, F-22 |
| BA-11 | Lieferschein erstellen | F-08, F-09, F-10, F-22 |
| BA-12 | Rechnung erstellen | F-11, F-12, F-13, F-14, F-15 |
| BA-13 | Geführte Rechnungserstellung | F-16, F-17, F-18 |
| BA-14 | Rechnung stornieren | F-19, F-20, F-21 |
| GR-01 | Lückenlose Rechnungsnummern | F-12 (Belegnummern-Regel) |
| GR-02 | Unveränderlichkeit versendeter Dokumente | F-24, F-21, NF-INT-01 |
| GR-03 | Steuerberechnung (Snapshot) | F-23, F-03, F-13 |
| GR-05 | Dokumentenzyklus-Konsistenz | F-22, F-06, F-09 |
| GR-06 | Standard-Zahlungsziel 14 Tage | F-14 |
| Q-03 | Performance PDF-Erstellung ≤ 2 s | NF-PERF-01 |
| Q-05 | Usability Ersterstellung Rechnung | NF-USE-01 |
| Q-07 | Unveränderlichkeit versendeter Rechnungen | NF-INT-01, F-24 |
| Q-09 | Pflichtfeldhinweise ≥ 80 % | NF-USE-02, F-18 |
> Hinweis: GR-04 (Löschsperre für verknüpfte Kunden) liegt in der Verantwortung von
> Gruppe C; Komponente A nutzt Kundendaten nur lesend (IF/`KundenService`) und ist von
> dieser Regel betroffen, spezifiziert sie aber nicht.
---
## 10. Modultestplan
Die folgenden Testfälle sind deterministisch (feste Ein-/Ausgaben) und mit JUnit 5
umsetzbar. Geldbeträge werden als `BigDecimal` mit Scale 2 erwartet
(`assertEquals(new BigDecimal("119.00"), …)` bzw. `compareTo`).
| TC | Abgedeckte PH-Anf. | Vorbedingung | Eingabe | Erwartetes Ergebnis |
|-------|--------------------|--------------|---------|---------------------|
| TC-01 | F-23, F-03 | Position mit Netto 100.00 €, Steuersatz 0.19 | `berechne()` | Steuer = 19.00, Brutto = 119.00 (Scale 2) |
| TC-02 | F-23 | Einzelpreis 50.00 €, Menge 3 | Positionssumme berechnen | positionssummeNetto = 150.00 |
| TC-03 | F-03, F-13 | Beleg mit 2 Positionen (150.00 € @ 0.19; 50.00 € @ 0.07) | Summen berechnen | summeNetto = 200.00, summeSteuer = 32.00, summeBrutto = 232.00 |
| TC-04 | F-12, GR-01 | Letzte Rechnungsnummer `R-2026-000123` | `naechsteNummer(RECHNUNG, 2026)` | liefert `R-2026-000124` (lückenlos) |
| TC-05 | F-12 (Format) | Zähler = 7, Jahr 2026 | `naechsteNummer(RECHNUNG, 2026)` | liefert `R-2026-000007` (führende Nullen, `String`) |
| TC-06 | F-14, GR-06 | Rechnungsdatum 2026-06-09, kein Zahlungsziel | Rechnung erstellen | zahlungsziel = 2026-06-23 (+14 Tage) |
| TC-07 | F-14 | Rechnungsdatum 2026-06-09, Zahlungsziel 2026-07-31 | Rechnung erstellen | zahlungsziel = 2026-07-31 (übernommen) |
| TC-08 | F-24, NF-INT-01 | Rechnung im Status `VERSENDET` | `setzePosition(...)` / Änderung | wirft `IllegalStateException` |
| TC-09 | F-19, F-20 | Rechnung im Status `OFFEN` | `storniere()` | Status = `STORNIERT`; nicht in `offeneRechnungen()`; `storniertAm` gesetzt |
| TC-10 | F-22, GR-05 | Angebot `AN-2026-000001` mit Kunde + 2 Positionen | `ausAngebot(angebot)` (AB erzeugen) | AB übernimmt Kunde/Positionen/Mengen; `vorgaengerNr = "AN-2026-000001"` |
| TC-11 | F-23, F-24 | Rechnung mit Produkt @ 50.00 €; danach Produktpreis → 80.00 € | erste Rechnung erneut lesen | einzelpreisNetto bleibt 50.00 (Snapshot unverändert) |
| TC-12 | F-18, NF-USE-02 | Rechnung ohne Kunde **oder** ohne Position | `speichere()` | Speichern abgelehnt; Validierungsfehler benennt fehlendes Pflichtfeld |
| TC-13 | F-11, F-12, F-13 | Kunde + 1 Position vorhanden | vollständige Rechnung erstellen | Rechnung gespeichert, Nummer vergeben, alle § 14 UStG-Pflichtangaben gesetzt |
Damit sind 13 Testfälle (> 10) spezifiziert, die alle funktionalen Kernregeln (F-12, F-14,
F-18, F-22, F-23, F-24) sowie die zentralen Geschäftsregeln (GR-01, GR-02, GR-03, GR-05,
GR-06) abdecken.
---
## 11. Anhänge
### 11.1 Abkürzungen
| Abkürzung | Bedeutung |
|-----------|-----------|
| F | Funktionale Anforderung (Pflichtenheft) |
| NF | Nicht-funktionale Anforderung (Pflichtenheft) |
| IF | Schnittstelle (Interface) |
| AC | Abnahmekriterium |
| TC | Testfall (Test Case) |
| BA | Benutzeranforderung (Lastenheft) |
| GR | Geschäftsregel (Lastenheft) |
| Q | Qualitätsanforderung (Lastenheft) |
| SRS | System Requirements Specification (Pflichtenheft) |
### 11.2 Glossar
Es gilt das Glossar des Lastenhefts (§ 8.1) unverändert.
### 11.3 Referenzen
Siehe Kapitel 1.5.

Binary file not shown.

View File

@ -0,0 +1,503 @@
---
title: "Pflichtenheft"
subtitle: "Desktop-Fakturierungsanwendung — Gruppe B: Verwaltung von Produkten"
author:
- Team 1 Gruppe B
version: "1.0"
lang: de-DE
toc: true
toc-depth: 3
numbersections: false
papersize: a4
geometry: "margin=3cm"
fontsize: 12pt
linestretch: 1.5
mainfont: "Times New Roman"
sansfont: "Arial"
monofont: "DejaVu Sans Mono"
header-includes: |
\usepackage{fancyhdr}
\usepackage{lastpage}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{Team 1 Gruppe B}
\fancyhead[C]{Pflichtenheft}
\fancyhead[R]{Version 1.0}
\fancyfoot[C]{\thepage\ /\ \pageref{LastPage}}
\renewcommand{\headrulewidth}{0.4pt}
\renewcommand{\footrulewidth}{0pt}
---
\newpage
+-----------------------------+-------------------------+-------------------------+
| Autor | Prüfer | Freigebender |
+=============================+=========================+=========================+
| Berhane, Meron\ | Prof. Dr. Marmitt, Gerd | Prof. Dr. Marmitt, Gerd |
| SchulzeAmeling, Jan-Micah\ | | |
| Volz, Jessica | | |
+-----------------------------+-------------------------+-------------------------+
| Gruppe B (Produkte) | Modulverantwortlicher | Modulverantwortlicher |
+-----------------------------+-------------------------+-------------------------+
| 10.06.2026 | 10.06.2026 | 10.06.2026 |
+-----------------------------+-------------------------+-------------------------+
**Freigabevermerk:** Dieses Dokument ist nach Prüfung und Freigabe durch den
Modulverantwortlichen verbindliche Spezifikationsgrundlage für die Implementierung
und den Modultest der Komponente *Verwaltung von Produkten*.
## Dokumentenhistorie
| Version | Datum | Autor | Grund der Änderung |
|---------|------------|----------------------------------------------------|---------------------|
| 1.0 | 10.06.2026 | Meron Berhane, Jan-Micah SchulzeAmeling, Jessica Volz | Initiale Erstellung |
\newpage
## 1. Einleitung
### 1.1 Zweck des Dokuments
Dieses Pflichtenheft (System Requirements Specification, SRS) beschreibt aus Sicht des
Auftragnehmers, **wie** die Komponente *Verwaltung von Produkten* der
Desktop-Fakturierungsanwendung die Anforderungen des Lastenhefts (v1.3) erfüllt. Es
konkretisiert die fachlichen Anforderungen in testbare Systemanforderungen und dient als
direkte Grundlage für Design, Implementierung sowie den Komponenten- bzw. Modultestplan
(Kapitel 10).
### 1.2 Ziel
Ziel dieses Pflichtenhefts ist die vollständige und testbare Spezifikation der Verwaltung
von Produktstammdaten: Anlegen, Ändern, Löschen (mit Löschsperre) sowie Suchen und
Auflisten von Produkten, einschließlich der Vergabe eindeutiger Produktnummern, der
Validierung der Eingaben und der Bereitstellung der Produktdaten für den Dokumentenzyklus
(Gruppe A).
### 1.3 Geltungsbereich
Dieses Dokument gilt für die Komponente **Gruppe B — Verwaltung von Produkten**. Die
Gesamtanwendung wird arbeitsteilig in vier Komponenten entwickelt; jede Untergruppe pflegt
ein eigenes Pflichtenheft:
| Gruppe | Komponente | Eigenes Pflichtenheft |
|--------|-------------------------|-----------------------|
| A | Prozess / Dokumentenzyklus | separat |
| B | Verwaltung von Produkten | **dieses Dokument** |
| C | Verwaltung von Kunden | separat |
| D | Programmoberfläche | separat |
Die Komponente B **stellt** Produktstammdaten lesend für den Dokumentenzyklus (Gruppe A)
über eine definierte interne Schnittstelle bereit (Kapitel 6.2) und wird über die
Programmoberfläche (Gruppe D) bedient. Die Erzeugung von Belegen, die Snapshot-Bildung
von Preisen und Steuersätzen in Dokumentpositionen (GR-03) sowie die Kundenverwaltung
(Gruppe C) sind **nicht** Gegenstand dieses Dokuments.
### 1.4 Definitionen und Abkürzungen
Fachbegriffe (Produkt, Dokumentposition, Snapshot, GoBD, DSGVO, CRUD, …) sind im Glossar
des Lastenhefts (§ 8.1) definiert und gelten unverändert. Dokumentspezifische Abkürzungen
siehe Kapitel 11.
### 1.5 Referenzen
- Lastenheft „Desktop-Fakturierungsanwendung", Team 1, Version 1.3, 09.06.2026
- Project Charter, Team 1, Version 1.3, 14.05.2026
- Pflichtenheft Gruppe A „Prozess / Dokumentenzyklus", Version 1.0, 09.06.2026
- GoBD — Grundsätze zur ordnungsmäßigen Führung und Aufbewahrung von Büchern
- DSGVO — EU-Verordnung 2016/679
- Vorlesungsunterlagen Software Engineering 1 (SoSe 2026), Foliensatz „Lasten- und Pflichtenheft"
---
## 2. Systemüberblick
### 2.1 Kurzbeschreibung
Die Anwendung ist eine **Einzelplatz-Stand-Alone-Desktop-Anwendung** mit **lokaler
Datenhaltung** (keine Cloud, kein Server). Die Bedienung erfolgt über eine **minimale
grafische Benutzeroberfläche** (Gruppe D), über die die Funktionalität der
Produktverwaltung zugänglich gemacht wird.
Die Komponente *Verwaltung von Produkten* stellt die Stammdatenpflege des Produktkatalogs
bereit: Anlegen, Ändern und Löschen von Produkten, Vergabe eindeutiger Produktnummern,
Validierung der Eingaben, Suche und sortierte Auflistung sowie der lesende Zugriff für die
Dokumenterstellung (Gruppe A) und der Export der Produktstammdaten in einem offenen Format.
### 2.2 Abgrenzung (Was gehört dazu / was nicht)
**Im Umfang dieser Komponente:**
- Anlegen von Produkten mit Pflicht- und optionalen Feldern
- Vergabe eindeutiger, vom System generierter Produktnummern
- Ändern von Produktdaten (Bezeichnung, Preis, Steuersatz, …)
- Löschen von Produkten inkl. Löschsperre bei referenzierten Produkten
- Suchen und sortiertes Auflisten von Produkten
- Bereitstellung des lesenden Zugriffs für Gruppe A (`ProduktService`)
- Export der Produktstammdaten in einem offenen, dokumentierten Format (Anteil an Q-08)
**Nicht im Umfang dieser Komponente:**
- Erstellung und Statusführung von Belegen (Gruppe A)
- Snapshot-Bildung von Preis/Steuersatz in Dokumentpositionen (GR-03, Gruppe A)
- Verwaltung von Kunden (Gruppe C)
- Aufbau und Layout der GUI (Gruppe D)
- Lagerverwaltung, Bestandsführung, Webshop-Anbindung (LH-Nichtziele)
### 2.3 Grobe Systemfunktionen
Erfassen eines Produkts → Validieren der Eingaben → Vergeben der Produktnummer →
Persistieren → Suchen/Auflisten → Ändern → Löschen (mit Referenzprüfung) → Export.
### 2.4 UML-Bezug
Ein gemeinsames Use-Case-Diagramm aller Gruppen gibt den Überblick über die Akteure und
Ziele. Die für Gruppe B relevanten Use Cases sind: *Produkt anlegen*, *Produktdaten
ändern*, *Produkt löschen* und *Produkte suchen und auflisten*. Die detaillierte logische
Architektur dieser Komponente folgt in Kapitel 7.
---
## 3. Stakeholder und Kontext
Stakeholder und Systemkontext sind im Lastenheft (§ 2, § 3) beschrieben und gelten
unverändert. Für diese Komponente ist der maßgebliche Akteur:
- **Anwender:in** — natürliche Person (Selbstständige:r, Freiberufler:in,
Kleinstunternehmer:in), die den Produktkatalog eigenverantwortlich pflegt.
Angrenzende Systeme/Komponenten: lokales Dateisystem (Persistenz, Datenexport) sowie
intern die Komponenten Dokumentenzyklus (A, lesender Konsument der Produktdaten) und
Programmoberfläche (D).
---
## 4. Funktionale Anforderungen
Die Anforderungen sind nach CRUD-Operationen gruppiert und mit den Satzschablonen des
Foliensatzes formuliert. Jede Anforderung ist eindeutig, vollständig, widerspruchsfrei und
verifizierbar.
> **Produktnummern (übergreifend):** Produktnummern sind **eindeutig** und werden
> **vom System generiert** (nicht durch den Anwender eingegeben). Sie werden als
> `String` geführt, **nicht** als `int`, weil die Nummern ein festes Format mit
> Präfix und führenden Nullen besitzen (z. B. `P-000042`); ein ganzzahliger Typ
> würde führende Nullen verlieren. Die Nummer wird fortlaufend auf Basis der höchsten
> bisher vergebenen Nummer ermittelt und ist nach der Vergabe **unveränderlich**.
> Anders als bei Rechnungsnummern (GR-01, Gruppe A) besteht keine
> Lückenlosigkeits-Pflicht.
### 4.1 Produkt anlegen (aus BA-05)
**F-01:** Das System MUSS es der Anwender:in ERMÖGLICHEN, ein neues Produkt mit den
Pflichtfeldern Bezeichnung, Netto-Einzelpreis und Steuersatz sowie den optionalen Feldern
Beschreibung und Einheit anzulegen.
**F-02:** WENN ein Produkt gespeichert wird, DANN MUSS das System eine eindeutige
Produktnummer (Präfix `P-`, fortlaufend, führende Nullen) vergeben und anzeigen.
**F-03:** WENN ein Produkt gespeichert wird, DANN MUSS das System die Eingaben validieren:
der Netto-Einzelpreis MUSS größer oder gleich `0.00` sein und der Steuersatz MUSS einem
der zulässigen Werte `{0.00, 0.07, 0.19}` entsprechen; andernfalls MUSS das Speichern
abgelehnt werden.
**F-04:** WENN ein Pflichtfeld fehlt (keine Bezeichnung, kein Einzelpreis, kein
Steuersatz), DANN MUSS das System das Speichern ablehnen und das fehlende Pflichtfeld
benennen (Q-09).
### 4.2 Produktdaten ändern (aus BA-06)
**F-05:** Das System MUSS es der Anwender:in ERMÖGLICHEN, die Felder Bezeichnung,
Beschreibung, Netto-Einzelpreis, Steuersatz und Einheit eines bestehenden Produkts zu
ändern und persistent zu speichern.
**F-06:** WENN ein Produkt geändert wird, DANN MUSS das System sicherstellen, dass
ausschließlich **neue** Dokumente den geänderten Wert verwenden; bereits erstellte
Dokumente bleiben unverändert, da Gruppe A Preis und Steuersatz als Snapshot in der
Dokumentposition ablegt (GR-02, GR-03). Die Komponente B speichert ausschließlich den
jeweils aktuellen Stand.
**F-07:** Das System MUSS die Produktnummer nach der Vergabe vor jeder Änderung schützen;
ein Änderungsversuch an der Produktnummer MUSS abgelehnt werden.
### 4.3 Produkt löschen (aus BA-07)
**F-08:** Das System MUSS es der Anwender:in ERMÖGLICHEN, ein nicht referenziertes Produkt
nach einer Bestätigungsabfrage dauerhaft zu löschen.
**F-09:** WENN das zu löschende Produkt in mindestens einer Dokumentposition referenziert
wird, DANN MUSS das System den Löschvorgang ablehnen und einen Hinweis anzeigen, dass das
Produkt in Dokumenten verwendet wird.
**F-10:** WENN ein Löschvorgang ausgelöst wird, DANN MUSS das System vor dem Löschen über
die Schnittstelle `ProduktReferenzPruefung` (Gruppe A, Kapitel 6.2) prüfen, ob das Produkt
in Dokumentpositionen referenziert ist.
### 4.4 Produkte suchen und auflisten (aus BA-08)
**F-11:** Das System MUSS es der Anwender:in ERMÖGLICHEN, alle Produkte in einer nach
Bezeichnung sortierten Liste anzuzeigen.
**F-12:** Das System MUSS es der Anwender:in ERMÖGLICHEN, Produkte über eine Suche nach
Bezeichnung oder Produktnummer zu filtern; die Suche MUSS Teilzeichenketten finden und
Groß-/Kleinschreibung ignorieren.
**F-13:** WENN eine Suche ausgeführt wird, DANN MUSS das System das gefilterte Ergebnis
innerhalb der Vorgabe aus Q-02 anzeigen (siehe NF-PERF-01).
### 4.5 Übergreifende Regeln und Dienste
**F-14 (Bereitstellung für Gruppe A):** Das System MUSS eine lesende Schnittstelle
`ProduktService` bereitstellen, die es der Komponente Dokumentenzyklus (Gruppe A)
ERMÖGLICHT, ein Produkt anhand seiner Produktnummer abzurufen; existiert kein Produkt zur
Nummer, MUSS `null` zurückgegeben werden.
**F-15 (Datenexport, Anteil an Q-08):** Das System MUSS es der Anwender:in ERMÖGLICHEN,
alle Produktstammdaten vollständig in ein offenes, dokumentiertes Format (CSV, UTF-8,
Semikolon-getrennt, mit Kopfzeile) in das lokale Dateisystem zu exportieren.
---
## 5. Nicht-funktionale Anforderungen
**NF-PERF-01 (aus Q-01/Q-02):** Das System MUSS Such- und Auflistungsergebnisse der
Produktverwaltung INNERHALB VON 1 SEKUNDE anzeigen, bei einem Datenbestand von bis zu
5.000 Produkten (Q-01) auf einem typischen Endanwender-PC.
**NF-EXP-01 (aus Q-08, anteilig):** Das System MUSS den vollständigen Export der
Produktstammdaten (F-15) INNERHALB VON 30 SEKUNDEN abschließen, bei einem Datenbestand
gemäß Q-01.
**NF-USE-01 (aus Q-09):** Das System MUSS fehlende Pflichtangaben im Formular „Produkt
anlegen/ändern" so markieren und benennen, dass mindestens 80 % der Testpersonen die
fehlende Eingabe ohne externe Hilfe im ersten Korrekturversuch ergänzen können (Nachweis
durch Usability-Test mit mind. 5 Testpersonen).
**NF-SEC-01 (aus Q-06, anteilig):** Das System MUSS alle Produktdaten ausschließlich lokal
auf dem Anwender-PC ablegen; eine Übertragung an externe Dienste findet NICHT statt.
---
## 6. Daten und Schnittstellen
Dieses Kapitel ist direkter Input für den Modultestplan (Kapitel 10). Datentypen werden
bereits als Java-Typen angegeben.
### 6.1 Datenobjekte und Datentypen
**Designgrundsätze (konsistent zu Gruppe A):**
- **Geldbeträge** werden als `java.math.BigDecimal` mit **Scale 2** und kaufmännischer
Rundung (`RoundingMode.HALF_UP`) geführt — **nicht** als `double` (Gleitkomma-Rundungs­fehler
wären für Beträge unzulässig).
- **Produktnummern** werden als `String` geführt (festes Format mit Präfix und führenden
Nullen, z. B. `"P-000042"`) — **nicht** als `int`.
- **Steuersätze** werden als `BigDecimal` als Faktor geführt (z. B. `0.19`); zulässige
Werte: `0.00`, `0.07`, `0.19`.
#### Klasse `Produkt`
| Attribut | Java-Typ | Beschreibung |
|-------------------|---------------|--------------|
| produktnummer | `String` | eindeutig, vom System generiert, unveränderlich (F-02, F-07) |
| bezeichnung | `String` | Pflichtfeld, nicht leer |
| beschreibung | `String` (optional, `null`) | Freitext |
| einzelpreisNetto | `BigDecimal` | Pflichtfeld, Scale 2, ≥ 0.00 |
| steuersatz | `BigDecimal` | Pflichtfeld, Faktor aus `{0.00, 0.07, 0.19}` |
| einheit | `String` (optional, `null`) | z. B. `"Stück"`, `"Stunde"` |
### 6.2 Schnittstellen
**Externe Schnittstellen:**
| ID | Schnittstelle | Zweck |
|-------|---------------------------|-------|
| IF-01 | Lokales Dateisystem | Persistenz der Produktstammdaten |
| IF-04 | Datenexport-Schnittstelle | Export der Produktstammdaten als CSV (F-15, Q-08) |
**Interne Schnittstellen (zu anderen Komponenten), als Java-Interfaces skizziert:**
```java
// Von Gruppe B IMPLEMENTIERT, von Gruppe A genutzt (lesender Zugriff)
public interface ProduktService {
Produkt findeProdukt(String produktnummer); // null, wenn nicht vorhanden
}
// Von Gruppe A BEREITGESTELLT, von Gruppe B genutzt (Löschsperre, F-10)
public interface ProduktReferenzPruefung {
boolean istProduktReferenziert(String produktnummer);
}
```
**Komponenteninterne Dienste:**
```java
public interface ProduktnummernGenerator {
// liefert die nächste fortlaufende Produktnummer, z. B. "P-000042"
String naechsteNummer();
}
public interface ProduktRepository {
Produkt speichere(Produkt produkt);
void loesche(String produktnummer);
List<Produkt> alleSortiertNachBezeichnung();
List<Produkt> suche(String suchbegriff); // Bezeichnung ODER Produktnummer
}
```
> IF-Satzschablone (Beispiel IF-04): *Das System MUSS eine Export-Schnittstelle
> bereitstellen, die es der Anwender:in ERMÖGLICHT, alle Produktstammdaten als
> CSV-Datei (UTF-8, Semikolon-getrennt) in das lokale Dateisystem
> (`java.nio.file.Path`) zu exportieren.*
---
## 7. Systemarchitektur (logisch, grob)
Die Komponente folgt einer einfachen Schichtung: die GUI (Gruppe D) ruft den
`ProduktVerwaltungsService` auf, der die Fachlogik (Validierung, Nummernvergabe,
Löschsperre) kapselt und die Dienste `ProduktnummernGenerator`, `ProduktRepository` und
`ProduktReferenzPruefung` (Gruppe A) nutzt. Gegenüber Gruppe A implementiert die
Komponente das Interface `ProduktService`. Produkte werden über das `ProduktRepository`
im lokalen Dateisystem persistiert.
### 7.1 Klassendiagramm
<!-- TODO: UML-Klassendiagramm hier einfügen (Abbildung 1) -->
![Abbildung 1: UML-Klassendiagramm Produktverwaltung (Gruppe B)]
**Beschreibung zu Abbildung 1:** Das Klassendiagramm zeigt die Entitätsklasse `Produkt`
mit ihren Attributen (Kapitel 6.1). Der `ProduktVerwaltungsService` orchestriert Anlegen,
Ändern, Löschen und Suche: er nutzt den `ProduktnummernGenerator` (Vergabe eindeutiger
Produktnummern, F-02), das `ProduktRepository` (Persistenz, IF-01) und die von Gruppe A
bereitgestellte Schnittstelle `ProduktReferenzPruefung` (Löschsperre, F-09/F-10).
Zusätzlich realisiert der `ProduktVerwaltungsService` das Interface `ProduktService`
(lesender Zugriff für Gruppe A, F-14). Dokumentpositionen (Gruppe A) referenzieren ein
`Produkt` ausschließlich über die Produktnummer (lose Kopplung).
### 7.2 Sequenzdiagramm
<!-- TODO: UML-Sequenzdiagramm hier einfügen (Abbildung 2) -->
![Abbildung 2: UML-Sequenzdiagramm „Produkt löschen mit Löschsperre" (Gruppe B)]
**Beschreibung zu Abbildung 2:** Das Sequenzdiagramm stellt den Ablauf *Produkt löschen*
dar. Die Anwender:in löst über die GUI (Gruppe D) `loescheProdukt(produktnummer)` am
`ProduktVerwaltungsService` aus. Dieser prüft zuerst über
`ProduktReferenzPruefung.istProduktReferenziert(produktnummer)` (Gruppe A), ob das Produkt
in Dokumentpositionen verwendet wird. Liefert die Prüfung `true`, wird der Löschvorgang
abgelehnt und ein Hinweis an die GUI zurückgegeben (F-09). Liefert sie `false`, fordert
das System die Bestätigung der Anwender:in an (F-08) und löscht das Produkt anschließend
über `ProduktRepository.loesche(produktnummer)` dauerhaft aus dem lokalen Datenbestand.
---
## 8. Testbare Abnahmekriterien
**AC-B-01 (zu F-01F-04)** — *Produkt anlegen*
Vorbedingung: Modul Produktverwaltung geöffnet; höchste vergebene Produktnummer = `P-000041`.
Aktion: Anwender:in erfasst ein Produkt mit Bezeichnung „Beratungsstunde", Einzelpreis
`80.00`, Steuersatz `0.19` und speichert.
Erwartet: Das Produkt ist persistent gespeichert und trägt die Produktnummer `P-000042`;
die Nummer wird angezeigt.
**AC-B-02 (zu F-04, NF-USE-01)** — *Pflichtfeldprüfung*
Vorbedingung: Formular „Produkt anlegen" geöffnet.
Aktion: Anwender:in lässt den Einzelpreis leer und versucht zu speichern.
Erwartet: Das Speichern wird abgelehnt; das Feld „Einzelpreis (netto)" wird als fehlendes
Pflichtfeld markiert und benannt.
**AC-B-03 (zu F-05, F-06, GR-02/GR-03)** — *Produkt ändern, Snapshot-Verhalten*
Vorbedingung: Ein Produkt (`50.00` €) ist in einer früheren Rechnung (Gruppe A) erfasst.
Aktion: Anwender:in ändert den Einzelpreis auf `80.00` € und erstellt anschließend eine
neue Rechnung mit diesem Produkt.
Erwartet: Die Änderung ist gespeichert; die alte Rechnung behält den ursprünglichen Preis
(Snapshot bei Gruppe A), die neue Rechnung übernimmt `80.00` €.
**AC-B-04 (zu F-08F-10)** — *Löschsperre für referenzierte Produkte*
Vorbedingung: Produkt `P-000010` wird in einer Dokumentposition referenziert; Produkt
`P-000011` ist unverknüpft.
Aktion: Anwender:in versucht, `P-000010` zu löschen; anschließend löscht sie `P-000011`
nach Bestätigung.
Erwartet: Das Löschen von `P-000010` wird mit Hinweis abgelehnt; `P-000011` ist dauerhaft
entfernt und erscheint nicht mehr in der Liste.
**AC-B-05 (zu F-11F-13, NF-PERF-01)** — *Produkt suchen und auflisten*
Vorbedingung: Mindestens 100 Produkte sind im System.
Aktion: Anwender:in sucht ein Produkt anhand eines Teils der Bezeichnung.
Erwartet: Die sortierte Trefferliste erscheint in ≤ 1 Sekunde (Q-02); die Suche findet das
Produkt auch bei abweichender Groß-/Kleinschreibung.
**AC-B-06 (zu F-15, NF-EXP-01)** — *Produktstammdaten exportieren*
Vorbedingung: Mindestens 100 Produkte sind im System.
Aktion: Anwender:in exportiert die Produktstammdaten.
Erwartet: Eine CSV-Datei (UTF-8, Semikolon-getrennt, mit Kopfzeile) mit allen Produkten
und allen Attributen liegt im gewählten Zielordner; der Export dauert ≤ 30 Sekunden.
---
## 9. Traceability LH ↔ PH
Jede für Gruppe B relevante Lastenheft-Anforderung ist mindestens einer
Pflichtenheft-Anforderung zugeordnet.
| LH-Anforderung | Beschreibung (LH) | PH-Anforderung(en) |
|----------------|-------------------------------------------|---------------------------|
| BA-05 | Produkte anlegen | F-01, F-02, F-03, F-04 |
| BA-06 | Produktdaten ändern | F-05, F-06, F-07 |
| BA-07 | Produkte löschen | F-08, F-09, F-10 |
| BA-08 | Produkte suchen und auflisten | F-11, F-12, F-13 |
| GR-02 | Unveränderlichkeit versendeter Dokumente | F-06 (Abgrenzung) |
| Q-01 | Datenbestand 5.000 Produkte | NF-PERF-01 |
| Q-02 | Suche/Auflistung ≤ 1 s | NF-PERF-01, F-13 |
| Q-06 | Lokale Speicherung | NF-SEC-01 |
| Q-08 | Datenexport ≤ 30 s | F-15, NF-EXP-01 |
| Q-09 | Pflichtfeldhinweise ≥ 80 % | NF-USE-01, F-04 |
> Hinweis: GR-03 (Steuerberechnung/Snapshot) liegt in der Verantwortung von Gruppe A;
> Komponente B liefert lediglich den jeweils aktuellen Preis und Steuersatz über
> `ProduktService` und spezifiziert die Snapshot-Bildung nicht. PZ-01 (CRUD-Verwaltung
> der Produktstammdaten) wird durch BA-05BA-08 vollständig abgedeckt.
---
## 10. Modultestplan
Die folgenden Testfälle sind deterministisch (feste Ein-/Ausgaben) und mit JUnit 5
umsetzbar. Geldbeträge werden als `BigDecimal` mit Scale 2 erwartet
(`assertEquals(new BigDecimal("80.00"), …)` bzw. `compareTo`). Die Schnittstelle
`ProduktReferenzPruefung` (Gruppe A) wird im Modultest durch einen Stub/Mock ersetzt.
| TC | Abgedeckte PH-Anf. | Vorbedingung | Eingabe | Erwartetes Ergebnis |
|-------|--------------------|--------------|---------|---------------------|
| TC-01 | F-01, F-02 | Höchste Produktnummer `P-000041` | Produkt („Beratungsstunde", 80.00, 0.19) speichern | Produkt persistiert; Produktnummer = `P-000042` |
| TC-02 | F-02 (Format) | Zähler = 7 | `naechsteNummer()` | liefert `P-000007` (führende Nullen, `String`) |
| TC-03 | F-03 | gültiges Produkt | Einzelpreis `-1.00` | Speichern abgelehnt (Validierungsfehler „Einzelpreis") |
| TC-04 | F-03 | gültiges Produkt | Steuersatz `0.15` | Speichern abgelehnt (unzulässiger Steuersatz) |
| TC-05 | F-04, NF-USE-01 | Produkt ohne Bezeichnung | `speichere()` | Speichern abgelehnt; Validierungsfehler benennt „Bezeichnung" |
| TC-06 | F-05 | Produkt `P-000042` mit Preis 80.00 | Preis auf 95.00 ändern, speichern | gespeichertes Produkt hat einzelpreisNetto = 95.00 |
| TC-07 | F-07 | Produkt `P-000042` | Änderungsversuch der Produktnummer auf `P-999999` | wirft `IllegalArgumentException` / Änderung abgelehnt |
| TC-08 | F-08 | Produkt unverknüpft (Stub: `istProduktReferenziert``false`) | `loescheProdukt("P-000011")` mit Bestätigung | Produkt entfernt; nicht mehr in `alleSortiertNachBezeichnung()` |
| TC-09 | F-09, F-10 | Stub: `istProduktReferenziert("P-000010")``true` | `loescheProdukt("P-000010")` | Löschen abgelehnt; Produkt weiterhin vorhanden; Hinweis erzeugt |
| TC-10 | F-11 | Produkte „Zaun", „Anker", „Mast" | `alleSortiertNachBezeichnung()` | Reihenfolge: „Anker", „Mast", „Zaun" |
| TC-11 | F-12 | Produkt „Beratungsstunde" | `suche("BERATUNG")` | Trefferliste enthält „Beratungsstunde" (case-insensitive, Teilstring) |
| TC-12 | F-12 | Produkt `P-000042` | `suche("P-000042")` | Trefferliste enthält genau dieses Produkt |
| TC-13 | F-14 | Kein Produkt `P-999999` vorhanden | `findeProdukt("P-999999")` | liefert `null` |
| TC-14 | F-15 | 3 Produkte im Bestand | `exportiereCsv(ziel)` | CSV-Datei mit Kopfzeile + 3 Datenzeilen, Semikolon-getrennt, UTF-8 |
Damit sind 14 Testfälle (> 10) spezifiziert, die alle funktionalen Kernregeln (F-02,
F-03, F-04, F-07, F-09, F-12, F-14, F-15) sowie die relevanten Geschäftsregeln und
Qualitätsvorgaben (GR-02-Abgrenzung, Q-02, Q-08, Q-09) abdecken.
---
## 11. Anhänge
### 11.1 Abkürzungen
| Abkürzung | Bedeutung |
|-----------|-----------|
| F | Funktionale Anforderung (Pflichtenheft) |
| NF | Nicht-funktionale Anforderung (Pflichtenheft) |
| IF | Schnittstelle (Interface) |
| AC | Abnahmekriterium |
| TC | Testfall (Test Case) |
| BA | Benutzeranforderung (Lastenheft) |
| GR | Geschäftsregel (Lastenheft) |
| Q | Qualitätsanforderung (Lastenheft) |
| CSV | Comma-Separated Values (offenes Exportformat) |
| SRS | System Requirements Specification (Pflichtenheft) |
### 11.2 Glossar
Es gilt das Glossar des Lastenhefts (§ 8.1) unverändert.
### 11.3 Referenzen
Siehe Kapitel 1.5.

Binary file not shown.

View File

@ -0,0 +1,511 @@
---
title: "Pflichtenheft"
subtitle: "Desktop-Fakturierungsanwendung — Gruppe C: Verwaltung von Kunden"
author:
- Team 1 Gruppe C
version: "1.0"
lang: de-DE
toc: true
toc-depth: 3
numbersections: false
papersize: a4
geometry: "margin=3cm"
fontsize: 12pt
linestretch: 1.5
mainfont: "Times New Roman"
sansfont: "Arial"
monofont: "DejaVu Sans Mono"
header-includes: |
\usepackage{fancyhdr}
\usepackage{lastpage}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{Team 1 Gruppe C}
\fancyhead[C]{Pflichtenheft}
\fancyhead[R]{Version 1.0}
\fancyfoot[C]{\thepage\ /\ \pageref{LastPage}}
\renewcommand{\headrulewidth}{0.4pt}
\renewcommand{\footrulewidth}{0pt}
---
\newpage
+-----------------------------+-------------------------+-------------------------+
| Autor | Prüfer | Freigebender |
+=============================+=========================+=========================+
| Ahadyar, Mahsuna\ | Prof. Dr. Marmitt, Gerd | Prof. Dr. Marmitt, Gerd |
| Kilic, Kübra\ | | |
| Weidmann, Mara | | |
+-----------------------------+-------------------------+-------------------------+
| Gruppe C (Kunden) | Modulverantwortlicher | Modulverantwortlicher |
+-----------------------------+-------------------------+-------------------------+
| 10.06.2026 | 10.06.2026 | 10.06.2026 |
+-----------------------------+-------------------------+-------------------------+
**Freigabevermerk:** Dieses Dokument ist nach Prüfung und Freigabe durch den
Modulverantwortlichen verbindliche Spezifikationsgrundlage für die Implementierung
und den Modultest der Komponente *Verwaltung von Kunden*.
## Dokumentenhistorie
| Version | Datum | Autor | Grund der Änderung |
|---------|------------|--------------------------------------------|---------------------|
| 1.0 | 10.06.2026 | Mahsuna Ahadyar, Kübra Kilic, Mara Weidmann | Initiale Erstellung |
\newpage
## 1. Einleitung
### 1.1 Zweck des Dokuments
Dieses Pflichtenheft (System Requirements Specification, SRS) beschreibt aus Sicht des
Auftragnehmers, **wie** die Komponente *Verwaltung von Kunden* der
Desktop-Fakturierungsanwendung die Anforderungen des Lastenhefts (v1.3) erfüllt. Es
konkretisiert die fachlichen Anforderungen in testbare Systemanforderungen und dient als
direkte Grundlage für Design, Implementierung sowie den Komponenten- bzw. Modultestplan
(Kapitel 10).
### 1.2 Ziel
Ziel dieses Pflichtenhefts ist die vollständige und testbare Spezifikation der Verwaltung
von Kundenstammdaten: Anlegen, Ändern, Löschen (mit referenzieller Integrität gemäß
GR-04) sowie Suchen und Auflisten von Kunden, einschließlich der Vergabe eindeutiger
Kundennummern, der Validierung der Eingaben und der Bereitstellung der Kundendaten für
den Dokumentenzyklus (Gruppe A).
### 1.3 Geltungsbereich
Dieses Dokument gilt für die Komponente **Gruppe C — Verwaltung von Kunden**. Die
Gesamtanwendung wird arbeitsteilig in vier Komponenten entwickelt; jede Untergruppe pflegt
ein eigenes Pflichtenheft:
| Gruppe | Komponente | Eigenes Pflichtenheft |
|--------|-------------------------|-----------------------|
| A | Prozess / Dokumentenzyklus | separat |
| B | Verwaltung von Produkten | separat |
| C | Verwaltung von Kunden | **dieses Dokument** |
| D | Programmoberfläche | separat |
Die Komponente C **stellt** Kundenstammdaten lesend für den Dokumentenzyklus (Gruppe A)
über eine definierte interne Schnittstelle bereit (Kapitel 6.2) und wird über die
Programmoberfläche (Gruppe D) bedient. Die Erzeugung von Belegen und die Übernahme der
Kundendaten in Dokumente (Gruppe A) sowie die Produktverwaltung (Gruppe B) sind **nicht**
Gegenstand dieses Dokuments.
### 1.4 Definitionen und Abkürzungen
Fachbegriffe (Kunde, Dokumentenzyklus, GoBD, DSGVO, CRUD, …) sind im Glossar des
Lastenhefts (§ 8.1) definiert und gelten unverändert. Dokumentspezifische Abkürzungen
siehe Kapitel 11.
### 1.5 Referenzen
- Lastenheft „Desktop-Fakturierungsanwendung", Team 1, Version 1.3, 09.06.2026
- Project Charter, Team 1, Version 1.3, 14.05.2026
- Pflichtenheft Gruppe A „Prozess / Dokumentenzyklus", Version 1.0, 09.06.2026
- DSGVO — EU-Verordnung 2016/679 (personenbezogene Kundendaten)
- GoBD — Grundsätze zur ordnungsmäßigen Führung und Aufbewahrung von Büchern
- Vorlesungsunterlagen Software Engineering 1 (SoSe 2026), Foliensatz „Lasten- und Pflichtenheft"
---
## 2. Systemüberblick
### 2.1 Kurzbeschreibung
Die Anwendung ist eine **Einzelplatz-Stand-Alone-Desktop-Anwendung** mit **lokaler
Datenhaltung** (keine Cloud, kein Server). Die Bedienung erfolgt über eine **minimale
grafische Benutzeroberfläche** (Gruppe D), über die die Funktionalität der
Kundenverwaltung zugänglich gemacht wird.
Die Komponente *Verwaltung von Kunden* stellt die Stammdatenpflege der Kunden bereit:
Anlegen, Ändern und Löschen von Kunden, Vergabe eindeutiger Kundennummern, Validierung
der Eingaben, referenzielle Integrität gegenüber dem Dokumentenbestand (GR-04), Suche und
sortierte Auflistung sowie der lesende Zugriff für die Dokumenterstellung (Gruppe A) und
der Export der Kundenstammdaten in einem offenen Format.
### 2.2 Abgrenzung (Was gehört dazu / was nicht)
**Im Umfang dieser Komponente:**
- Anlegen von Kunden mit Pflicht- und optionalen Feldern
- Vergabe eindeutiger, vom System generierter Kundennummern
- Ändern von Kundendaten (Name, Anschrift, Kontaktdaten, USt-IdNr.)
- Löschen von Kunden inkl. Löschsperre bei verknüpften Dokumenten (GR-04)
- Suchen und sortiertes Auflisten von Kunden (Volltextsuche Name/Kundennummer)
- Bereitstellung des lesenden Zugriffs für Gruppe A (`KundenService`)
- Export der Kundenstammdaten in einem offenen, dokumentierten Format (Anteil an Q-08)
**Nicht im Umfang dieser Komponente:**
- Erstellung und Statusführung von Belegen, Übernahme der Kundendaten in Belege (Gruppe A)
- Verwaltung von Produkten (Gruppe B)
- Aufbau und Layout der GUI (Gruppe D)
- Mahnwesen, Buchhaltung, Kundenportale (LH-Nichtziele)
### 2.3 Grobe Systemfunktionen
Erfassen eines Kunden → Validieren der Eingaben → Vergeben der Kundennummer →
Persistieren → Suchen/Auflisten → Ändern → Löschen (mit Referenzprüfung, GR-04) → Export.
### 2.4 UML-Bezug
Ein gemeinsames Use-Case-Diagramm aller Gruppen gibt den Überblick über die Akteure und
Ziele. Die für Gruppe C relevanten Use Cases sind: *Kunde anlegen*, *Kundendaten ändern*,
*Kunde löschen* und *Kunden suchen und auflisten*. Die detaillierte logische Architektur
dieser Komponente folgt in Kapitel 7.
---
## 3. Stakeholder und Kontext
Stakeholder und Systemkontext sind im Lastenheft (§ 2, § 3) beschrieben und gelten
unverändert. Für diese Komponente ist der maßgebliche Akteur:
- **Anwender:in** — natürliche Person (Selbstständige:r, Freiberufler:in,
Kleinstunternehmer:in), die ihre Kundenstammdaten eigenverantwortlich pflegt.
Angrenzende Systeme/Komponenten: lokales Dateisystem (Persistenz, Datenexport) sowie
intern die Komponenten Dokumentenzyklus (A, lesender Konsument der Kundendaten) und
Programmoberfläche (D). Da Kundendaten **personenbezogene Daten** im Sinne der DSGVO
sind, gilt die lokale Datenhaltung (Q-06) für diese Komponente in besonderem Maße.
---
## 4. Funktionale Anforderungen
Die Anforderungen sind nach CRUD-Operationen gruppiert und mit den Satzschablonen des
Foliensatzes formuliert. Jede Anforderung ist eindeutig, vollständig, widerspruchsfrei und
verifizierbar.
> **Kundennummern (übergreifend):** Kundennummern sind **eindeutig** und werden
> **vom System generiert** (nicht durch den Anwender eingegeben). Sie werden als
> `String` geführt, **nicht** als `int`, weil die Nummern ein festes Format mit
> Präfix und führenden Nullen besitzen (z. B. `K-000017`); ein ganzzahliger Typ
> würde führende Nullen verlieren. Die Nummer wird fortlaufend auf Basis der höchsten
> bisher vergebenen Nummer ermittelt und ist nach der Vergabe **unveränderlich**.
> Anders als bei Rechnungsnummern (GR-01, Gruppe A) besteht keine
> Lückenlosigkeits-Pflicht.
### 4.1 Kunde anlegen (aus BA-01)
**F-01:** Das System MUSS es der Anwender:in ERMÖGLICHEN, einen neuen Kunden mit den
Pflichtfeldern Name und Anschrift (Straße, PLZ, Ort) sowie den optionalen Feldern E-Mail,
Telefon und USt-IdNr. anzulegen.
**F-02:** WENN ein Kunde gespeichert wird, DANN MUSS das System eine eindeutige
Kundennummer (Präfix `K-`, fortlaufend, führende Nullen) vergeben und anzeigen.
**F-03:** WENN ein Pflichtfeld fehlt (kein Name, keine vollständige Anschrift), DANN MUSS
das System das Speichern ablehnen und das fehlende Pflichtfeld benennen (Q-09).
**F-04:** WENN eine E-Mail-Adresse angegeben wird, DANN MUSS das System deren Format
prüfen (mindestens ein `@` mit Zeichen davor und dahinter) und bei ungültigem Format das
Speichern ablehnen.
### 4.2 Kundendaten ändern (aus BA-02)
**F-05:** Das System MUSS es der Anwender:in ERMÖGLICHEN, die Felder Name, Anschrift,
E-Mail, Telefon und USt-IdNr. eines bestehenden Kunden zu ändern und persistent zu
speichern; die Pflichtfeldprüfung (F-03) gilt unverändert.
**F-06:** WENN ein Kunde geändert wird, DANN MUSS das System sicherstellen, dass bereits
versendete Dokumente unverändert bleiben; dies ist gewährleistet, weil Gruppe A die
Kundendaten zum Erstellzeitpunkt in den Beleg übernimmt (GR-02). Die Komponente C
speichert ausschließlich den jeweils aktuellen Stand.
**F-07:** Das System MUSS die Kundennummer nach der Vergabe vor jeder Änderung schützen;
ein Änderungsversuch an der Kundennummer MUSS abgelehnt werden.
### 4.3 Kunde löschen (aus BA-03, GR-04)
**F-08:** Das System MUSS es der Anwender:in ERMÖGLICHEN, einen Kunden ohne verknüpfte
Dokumente nach einer Bestätigungsabfrage dauerhaft zu löschen.
**F-09 (GR-04):** WENN der zu löschende Kunde aktive oder archivierte Dokumente
referenziert, DANN MUSS das System den Löschvorgang ablehnen und einen Hinweis mit der
**Anzahl der verknüpften Dokumente** anzeigen.
**F-10:** WENN ein Löschvorgang ausgelöst wird, DANN MUSS das System vor dem Löschen über
die Schnittstelle `KundenReferenzPruefung` (Gruppe A, Kapitel 6.2) die Anzahl der
Dokumente ermitteln, die den Kunden referenzieren.
### 4.4 Kunden suchen und auflisten (aus BA-04)
**F-11:** Das System MUSS es der Anwender:in ERMÖGLICHEN, alle Kunden in einer nach Name
sortierten Liste anzuzeigen.
**F-12:** Das System MUSS es der Anwender:in ERMÖGLICHEN, Kunden über eine Volltextsuche
nach Name oder Kundennummer zu filtern; die Suche MUSS Teilzeichenketten finden und
Groß-/Kleinschreibung ignorieren.
**F-13:** WENN eine Suche ausgeführt wird, DANN MUSS das System das gefilterte Ergebnis
innerhalb der Vorgabe aus Q-02 anzeigen (siehe NF-PERF-01).
### 4.5 Übergreifende Regeln und Dienste
**F-14 (Bereitstellung für Gruppe A):** Das System MUSS eine lesende Schnittstelle
`KundenService` bereitstellen, die es der Komponente Dokumentenzyklus (Gruppe A)
ERMÖGLICHT, einen Kunden anhand seiner Kundennummer abzurufen; existiert kein Kunde zur
Nummer, MUSS `null` zurückgegeben werden.
**F-15 (Datenexport, Anteil an Q-08):** Das System MUSS es der Anwender:in ERMÖGLICHEN,
alle Kundenstammdaten vollständig in ein offenes, dokumentiertes Format (CSV, UTF-8,
Semikolon-getrennt, mit Kopfzeile) in das lokale Dateisystem zu exportieren.
---
## 5. Nicht-funktionale Anforderungen
**NF-PERF-01 (aus Q-01/Q-02):** Das System MUSS Such- und Auflistungsergebnisse der
Kundenverwaltung INNERHALB VON 1 SEKUNDE anzeigen, bei einem Datenbestand von bis zu
5.000 Kunden (Q-01) auf einem typischen Endanwender-PC.
**NF-EXP-01 (aus Q-08, anteilig):** Das System MUSS den vollständigen Export der
Kundenstammdaten (F-15) INNERHALB VON 30 SEKUNDEN abschließen, bei einem Datenbestand
gemäß Q-01.
**NF-USE-01 (aus Q-09):** Das System MUSS fehlende Pflichtangaben im Formular „Kunde
anlegen/ändern" so markieren und benennen, dass mindestens 80 % der Testpersonen die
fehlende Eingabe ohne externe Hilfe im ersten Korrekturversuch ergänzen können (Nachweis
durch Usability-Test mit mind. 5 Testpersonen).
**NF-SEC-01 (aus Q-06, anteilig / DSGVO):** Das System MUSS 100 % der personenbezogenen
Kundendaten ausschließlich lokal auf dem Anwender-PC ablegen; eine Übertragung an externe
Dienste findet NICHT statt (Nachweis durch Netzwerk-Monitoring während eines
repräsentativen Nutzungslaufs).
---
## 6. Daten und Schnittstellen
Dieses Kapitel ist direkter Input für den Modultestplan (Kapitel 10). Datentypen werden
bereits als Java-Typen angegeben.
### 6.1 Datenobjekte und Datentypen
**Designgrundsätze (konsistent zu Gruppe A):**
- **Kundennummern** werden als `String` geführt (festes Format mit Präfix und führenden
Nullen, z. B. `"K-000017"`) — **nicht** als `int`.
- **Postleitzahlen** werden als `String` geführt — **nicht** als `int`, weil führende
Nullen erhalten bleiben müssen (z. B. `"01067"` Dresden).
- Optionale Felder sind als `null` zulässig; Pflichtfelder dürfen weder `null` noch leer
sein.
#### Klasse `Kunde`
| Attribut | Java-Typ | Beschreibung |
|---------------|---------------|--------------|
| kundennummer | `String` | eindeutig, vom System generiert, unveränderlich (F-02, F-07) |
| name | `String` | Pflichtfeld, nicht leer (Firmen- oder Personenname) |
| strasse | `String` | Pflichtfeld (Anschrift) |
| plz | `String` | Pflichtfeld (Anschrift, führende Nullen) |
| ort | `String` | Pflichtfeld (Anschrift) |
| eMail | `String` (optional, `null`) | Format gemäß F-04 |
| telefon | `String` (optional, `null`) | Freitext |
| ustIdNr | `String` (optional, `null`) | Umsatzsteuer-Identifikationsnummer |
### 6.2 Schnittstellen
**Externe Schnittstellen:**
| ID | Schnittstelle | Zweck |
|-------|---------------------------|-------|
| IF-01 | Lokales Dateisystem | Persistenz der Kundenstammdaten |
| IF-04 | Datenexport-Schnittstelle | Export der Kundenstammdaten als CSV (F-15, Q-08) |
**Interne Schnittstellen (zu anderen Komponenten), als Java-Interfaces skizziert:**
```java
// Von Gruppe C IMPLEMENTIERT, von Gruppe A genutzt (lesender Zugriff)
public interface KundenService {
Kunde findeKunde(String kundennummer); // null, wenn nicht vorhanden
}
// Von Gruppe A BEREITGESTELLT, von Gruppe C genutzt (Löschsperre GR-04, F-10)
public interface KundenReferenzPruefung {
// Anzahl aktiver und archivierter Dokumente, die den Kunden referenzieren
int anzahlVerknuepfterDokumente(String kundennummer);
}
```
**Komponenteninterne Dienste:**
```java
public interface KundennummernGenerator {
// liefert die nächste fortlaufende Kundennummer, z. B. "K-000017"
String naechsteNummer();
}
public interface KundenRepository {
Kunde speichere(Kunde kunde);
void loesche(String kundennummer);
List<Kunde> alleSortiertNachName();
List<Kunde> suche(String suchbegriff); // Name ODER Kundennummer
}
```
> IF-Satzschablone (Beispiel IF-04): *Das System MUSS eine Export-Schnittstelle
> bereitstellen, die es der Anwender:in ERMÖGLICHT, alle Kundenstammdaten als
> CSV-Datei (UTF-8, Semikolon-getrennt) in das lokale Dateisystem
> (`java.nio.file.Path`) zu exportieren.*
---
## 7. Systemarchitektur (logisch, grob)
Die Komponente folgt einer einfachen Schichtung: die GUI (Gruppe D) ruft den
`KundenVerwaltungsService` auf, der die Fachlogik (Validierung, Nummernvergabe,
Löschsperre GR-04) kapselt und die Dienste `KundennummernGenerator`, `KundenRepository`
und `KundenReferenzPruefung` (Gruppe A) nutzt. Gegenüber Gruppe A implementiert die
Komponente das Interface `KundenService`. Kunden werden über das `KundenRepository` im
lokalen Dateisystem persistiert.
### 7.1 Klassendiagramm
<!-- TODO: UML-Klassendiagramm hier einfügen (Abbildung 1) -->
![Abbildung 1: UML-Klassendiagramm Kundenverwaltung (Gruppe C)]
**Beschreibung zu Abbildung 1:** Das Klassendiagramm zeigt die Entitätsklasse `Kunde`
mit ihren Attributen (Kapitel 6.1). Der `KundenVerwaltungsService` orchestriert Anlegen,
Ändern, Löschen und Suche: er nutzt den `KundennummernGenerator` (Vergabe eindeutiger
Kundennummern, F-02), das `KundenRepository` (Persistenz, IF-01) und die von Gruppe A
bereitgestellte Schnittstelle `KundenReferenzPruefung` (Löschsperre GR-04, F-09/F-10).
Zusätzlich realisiert der `KundenVerwaltungsService` das Interface `KundenService`
(lesender Zugriff für Gruppe A, F-14). Dokumente (Gruppe A) referenzieren einen `Kunde`
ausschließlich über die Kundennummer (lose Kopplung).
### 7.2 Sequenzdiagramm
<!-- TODO: UML-Sequenzdiagramm hier einfügen (Abbildung 2) -->
![Abbildung 2: UML-Sequenzdiagramm „Kunde löschen mit Löschsperre (GR-04)" (Gruppe C)]
**Beschreibung zu Abbildung 2:** Das Sequenzdiagramm stellt den Ablauf *Kunde löschen*
dar. Die Anwender:in löst über die GUI (Gruppe D) `loescheKunde(kundennummer)` am
`KundenVerwaltungsService` aus. Dieser ermittelt zuerst über
`KundenReferenzPruefung.anzahlVerknuepfterDokumente(kundennummer)` (Gruppe A) die Anzahl
der Dokumente, die den Kunden referenzieren. Ist die Anzahl größer als 0, wird der
Löschvorgang abgelehnt und ein Hinweis mit der Anzahl der verknüpften Dokumente an die
GUI zurückgegeben (F-09, GR-04). Ist die Anzahl 0, fordert das System die Bestätigung der
Anwender:in an (F-08) und löscht den Kunden anschließend über
`KundenRepository.loesche(kundennummer)` dauerhaft aus dem lokalen Datenbestand.
---
## 8. Testbare Abnahmekriterien
**AC-C-01 (zu F-01F-03, NF-PERF-01)** — *Kunde anlegen und auffinden*
Vorbedingung: Anwendung gestartet, Modul Kundenverwaltung geöffnet; höchste vergebene
Kundennummer = `K-000016`.
Aktion: Anwender:in erfasst einen neuen Kunden mit Pflichtfeldern (Name, Straße, PLZ,
Ort) und speichert.
Erwartet: Das System vergibt die Kundennummer `K-000017` und zeigt sie an; der Kunde
erscheint in der Suchergebnisliste innerhalb von ≤ 1 Sekunde (Q-02).
**AC-C-02 (zu F-03, F-04, NF-USE-01)** — *Pflichtfeld- und Formatprüfung*
Vorbedingung: Formular „Kunde anlegen" geöffnet.
Aktion: Anwender:in lässt den Ort leer und versucht zu speichern; anschließend trägt sie
eine ungültige E-Mail-Adresse („max.mustermann") ein und versucht erneut zu speichern.
Erwartet: Beide Speicherversuche werden abgelehnt; das fehlende Pflichtfeld „Ort" bzw.
das ungültige E-Mail-Format wird benannt.
**AC-C-03 (zu F-05F-07)** — *Kundendaten ändern*
Vorbedingung: Ein Kunde mit mindestens einer verknüpften, versendeten Rechnung existiert.
Aktion: Anwender:in ändert einen Adressbestandteil und speichert.
Erwartet: Die Änderung ist persistent gespeichert; die bereits versendete Rechnung
(Gruppe A) zeigt weiterhin die ursprüngliche Anschrift; die Kundennummer ist unverändert.
**AC-C-04 (zu F-08F-10, GR-04)** — *Löschsperre für verknüpfte Kunden*
Vorbedingung: Kunde `K-000010` referenziert 3 Dokumente; Kunde `K-000011` ist unverknüpft.
Aktion: Anwender:in versucht, `K-000010` zu löschen; anschließend löscht sie `K-000011`
nach Bestätigung.
Erwartet: Das Löschen von `K-000010` wird abgelehnt, der Hinweis nennt die Anzahl „3"
verknüpfter Dokumente; `K-000011` ist dauerhaft entfernt und erscheint nicht mehr in der
Liste.
**AC-C-05 (zu F-11F-13, NF-PERF-01)** — *Kunden suchen und auflisten*
Vorbedingung: Mindestens 100 Kunden sind im System.
Aktion: Anwender:in sucht einen Kunden anhand eines Teils des Namens und anschließend
anhand der Kundennummer.
Erwartet: Beide Trefferlisten erscheinen in ≤ 1 Sekunde (Q-02), sind nach Name sortiert
und enthalten den gesuchten Kunden (auch bei abweichender Groß-/Kleinschreibung).
**AC-C-06 (zu F-15, NF-EXP-01, NF-SEC-01)** — *Kundenstammdaten exportieren*
Vorbedingung: Mindestens 100 Kunden sind im System.
Aktion: Anwender:in exportiert die Kundenstammdaten; während des Nutzungslaufs läuft ein
Netzwerk-Monitoring.
Erwartet: Eine CSV-Datei (UTF-8, Semikolon-getrennt, mit Kopfzeile) mit allen Kunden und
allen Attributen liegt im gewählten Zielordner; der Export dauert ≤ 30 Sekunden; das
Monitoring zeigt keine Datenübertragung an externe Dienste.
---
## 9. Traceability LH ↔ PH
Jede für Gruppe C relevante Lastenheft-Anforderung ist mindestens einer
Pflichtenheft-Anforderung zugeordnet.
| LH-Anforderung | Beschreibung (LH) | PH-Anforderung(en) |
|----------------|-------------------------------------------|---------------------------|
| BA-01 | Kunden anlegen | F-01, F-02, F-03, F-04 |
| BA-02 | Kundendaten ändern | F-05, F-06, F-07 |
| BA-03 | Kunden löschen | F-08, F-09, F-10 |
| BA-04 | Kunden suchen und auflisten | F-11, F-12, F-13 |
| GR-02 | Unveränderlichkeit versendeter Dokumente | F-06 (Abgrenzung) |
| GR-04 | Referenzielle Integrität Kunden | F-09, F-10 |
| Q-01 | Datenbestand 5.000 Kunden | NF-PERF-01 |
| Q-02 | Suche/Auflistung ≤ 1 s | NF-PERF-01, F-13 |
| Q-06 | Lokale Speicherung (DSGVO) | NF-SEC-01 |
| Q-08 | Datenexport ≤ 30 s | F-15, NF-EXP-01 |
| Q-09 | Pflichtfeldhinweise ≥ 80 % | NF-USE-01, F-03 |
> Hinweis: Die Übernahme der Kundendaten in Belege (Snapshot zum Erstellzeitpunkt) liegt
> in der Verantwortung von Gruppe A; Komponente C liefert lediglich den jeweils aktuellen
> Datenstand über `KundenService`. PZ-01 (CRUD-Verwaltung der Kundenstammdaten) wird
> durch BA-01BA-04 vollständig abgedeckt.
---
## 10. Modultestplan
Die folgenden Testfälle sind deterministisch (feste Ein-/Ausgaben) und mit JUnit 5
umsetzbar. Die Schnittstelle `KundenReferenzPruefung` (Gruppe A) wird im Modultest durch
einen Stub/Mock ersetzt.
| TC | Abgedeckte PH-Anf. | Vorbedingung | Eingabe | Erwartetes Ergebnis |
|-------|--------------------|--------------|---------|---------------------|
| TC-01 | F-01, F-02 | Höchste Kundennummer `K-000016` | Kunde („Muster GmbH", „Hauptstr. 1", „68163", „Mannheim") speichern | Kunde persistiert; Kundennummer = `K-000017` |
| TC-02 | F-02 (Format) | Zähler = 7 | `naechsteNummer()` | liefert `K-000007` (führende Nullen, `String`) |
| TC-03 | F-03, NF-USE-01 | Kunde ohne Ort | `speichere()` | Speichern abgelehnt; Validierungsfehler benennt „Ort" |
| TC-04 | F-03 | Kunde mit leerem Namen (`""`) | `speichere()` | Speichern abgelehnt; Validierungsfehler benennt „Name" |
| TC-05 | F-04 | Kunde mit E-Mail `"max.mustermann"` | `speichere()` | Speichern abgelehnt (ungültiges E-Mail-Format) |
| TC-06 | F-04 | Kunde mit E-Mail `"max@beispiel.de"` | `speichere()` | Kunde gespeichert (gültiges Format) |
| TC-07 | F-05 | Kunde `K-000017` mit Ort „Mannheim" | Ort auf „Heidelberg" ändern, speichern | gespeicherter Kunde hat ort = „Heidelberg" |
| TC-08 | F-07 | Kunde `K-000017` | Änderungsversuch der Kundennummer auf `K-999999` | wirft `IllegalArgumentException` / Änderung abgelehnt |
| TC-09 | F-08 | Stub: `anzahlVerknuepfterDokumente``0` | `loescheKunde("K-000011")` mit Bestätigung | Kunde entfernt; nicht mehr in `alleSortiertNachName()` |
| TC-10 | F-09, F-10, GR-04 | Stub: `anzahlVerknuepfterDokumente("K-000010")``3` | `loescheKunde("K-000010")` | Löschen abgelehnt; Kunde weiterhin vorhanden; Hinweis enthält Anzahl `3` |
| TC-11 | F-11 | Kunden „Zimmer", „Albrecht", „Maier" | `alleSortiertNachName()` | Reihenfolge: „Albrecht", „Maier", „Zimmer" |
| TC-12 | F-12 | Kunde „Muster GmbH" | `suche("MUSTER")` | Trefferliste enthält „Muster GmbH" (case-insensitive, Teilstring) |
| TC-13 | F-12, F-14 | Kunde `K-000017` vorhanden; `K-999999` nicht | `suche("K-000017")`; `findeKunde("K-999999")` | Treffer enthält `K-000017`; `findeKunde` liefert `null` |
| TC-14 | F-15 | 3 Kunden im Bestand | `exportiereCsv(ziel)` | CSV-Datei mit Kopfzeile + 3 Datenzeilen, Semikolon-getrennt, UTF-8 |
Damit sind 14 Testfälle (> 10) spezifiziert, die alle funktionalen Kernregeln (F-02,
F-03, F-04, F-07, F-09, F-12, F-14, F-15) sowie die zentrale Geschäftsregel GR-04 und
die Qualitätsvorgaben (Q-02, Q-08, Q-09) abdecken.
---
## 11. Anhänge
### 11.1 Abkürzungen
| Abkürzung | Bedeutung |
|-----------|-----------|
| F | Funktionale Anforderung (Pflichtenheft) |
| NF | Nicht-funktionale Anforderung (Pflichtenheft) |
| IF | Schnittstelle (Interface) |
| AC | Abnahmekriterium |
| TC | Testfall (Test Case) |
| BA | Benutzeranforderung (Lastenheft) |
| GR | Geschäftsregel (Lastenheft) |
| Q | Qualitätsanforderung (Lastenheft) |
| CSV | Comma-Separated Values (offenes Exportformat) |
| USt-IdNr. | Umsatzsteuer-Identifikationsnummer |
| SRS | System Requirements Specification (Pflichtenheft) |
### 11.2 Glossar
Es gilt das Glossar des Lastenhefts (§ 8.1) unverändert.
### 11.3 Referenzen
Siehe Kapitel 1.5.

Binary file not shown.

View File

@ -0,0 +1,541 @@
---
title: "Pflichtenheft"
subtitle: "Desktop-Fakturierungsanwendung — Gruppe D: Programmoberfläche"
author:
- Team 1 Gruppe D
version: "1.0"
lang: de-DE
toc: true
toc-depth: 3
numbersections: false
papersize: a4
geometry: "margin=3cm"
fontsize: 12pt
linestretch: 1.5
mainfont: "Times New Roman"
sansfont: "Arial"
monofont: "DejaVu Sans Mono"
header-includes: |
\usepackage{fancyhdr}
\usepackage{lastpage}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{Team 1 Gruppe D}
\fancyhead[C]{Pflichtenheft}
\fancyhead[R]{Version 1.0}
\fancyfoot[C]{\thepage\ /\ \pageref{LastPage}}
\renewcommand{\headrulewidth}{0.4pt}
\renewcommand{\footrulewidth}{0pt}
---
\newpage
+-----------------------------+-------------------------+-------------------------+
| Autor | Prüfer | Freigebender |
+=============================+=========================+=========================+
| Güngör, Mirkan\ | Prof. Dr. Marmitt, Gerd | Prof. Dr. Marmitt, Gerd |
| König, Moritz\ | | |
| Bouhki, Mohammed | | |
+-----------------------------+-------------------------+-------------------------+
| Gruppe D (Oberfläche) | Modulverantwortlicher | Modulverantwortlicher |
+-----------------------------+-------------------------+-------------------------+
| 10.06.2026 | 10.06.2026 | 10.06.2026 |
+-----------------------------+-------------------------+-------------------------+
**Freigabevermerk:** Dieses Dokument ist nach Prüfung und Freigabe durch den
Modulverantwortlichen verbindliche Spezifikationsgrundlage für die Implementierung
und den Modultest der Komponente *Programmoberfläche*.
## Dokumentenhistorie
| Version | Datum | Autor | Grund der Änderung |
|---------|------------|----------------------------------------------|---------------------|
| 1.0 | 10.06.2026 | Mirkan Güngör, Moritz König, Mohammed Bouhki | Initiale Erstellung |
\newpage
## 1. Einleitung
### 1.1 Zweck des Dokuments
Dieses Pflichtenheft (System Requirements Specification, SRS) beschreibt aus Sicht des
Auftragnehmers, **wie** die Komponente *Programmoberfläche* der
Desktop-Fakturierungsanwendung die Anforderungen des Lastenhefts (v1.3) erfüllt. Es
konkretisiert die fachlichen Anforderungen in testbare Systemanforderungen und dient als
direkte Grundlage für Design, Implementierung sowie den Komponenten- bzw. Modultestplan
(Kapitel 10).
### 1.2 Ziel
Ziel dieses Pflichtenhefts ist die vollständige und testbare Spezifikation der grafischen
Benutzeroberfläche: Hauptfenster und Navigation, Listen-, Such- und Formularansichten für
die Stammdatenmodule (Gruppen B und C), Belegansichten und -aktionen des Dokumentenzyklus
(Gruppe A), die geführte (schrittweise) Rechnungserstellung als Dialogfolge (Wizard), die
Stornierung mit Bestätigungsdialog sowie die einheitliche Pflichtfeld-Markierung und
Fehleranzeige.
### 1.3 Geltungsbereich
Dieses Dokument gilt für die Komponente **Gruppe D — Programmoberfläche**. Die
Gesamtanwendung wird arbeitsteilig in vier Komponenten entwickelt; jede Untergruppe pflegt
ein eigenes Pflichtenheft:
| Gruppe | Komponente | Eigenes Pflichtenheft |
|--------|-------------------------|-----------------------|
| A | Prozess / Dokumentenzyklus | separat |
| B | Verwaltung von Produkten | separat |
| C | Verwaltung von Kunden | separat |
| D | Programmoberfläche | **dieses Dokument** |
Die Komponente D enthält **keine Fachlogik**: Sie ruft die Dienste der Komponenten
Dokumentenzyklus (A, `DokumentService`), Produktverwaltung (B) und Kundenverwaltung (C)
über deren definierte Schnittstellen auf und stellt deren Funktionalität dar. Die
Benutzeranforderungen BA-13 (geführte Rechnungserstellung) und BA-14 (Rechnung
stornieren) werden arbeitsteilig spezifiziert: Gruppe A beschreibt die **Fachlogik**
(F-16F-21 im Pflichtenheft A), dieses Dokument beschreibt die **Dialogführung und
Darstellung**.
### 1.4 Definitionen und Abkürzungen
Fachbegriffe (Dokumentenzyklus, Rechnung, GoBD, DSGVO, …) sind im Glossar des Lastenhefts
(§ 8.1) definiert und gelten unverändert. Dokumentspezifische Abkürzungen siehe
Kapitel 11.
### 1.5 Referenzen
- Lastenheft „Desktop-Fakturierungsanwendung", Team 1, Version 1.3, 09.06.2026
- Project Charter, Team 1, Version 1.3, 14.05.2026
- Pflichtenheft Gruppe A „Prozess / Dokumentenzyklus", Version 1.0, 09.06.2026
- Pflichtenheft Gruppe B „Verwaltung von Produkten", Version 1.0, 10.06.2026
- Pflichtenheft Gruppe C „Verwaltung von Kunden", Version 1.0, 10.06.2026
- Vorlesungsunterlagen Software Engineering 1 (SoSe 2026), Foliensatz „Lasten- und Pflichtenheft"
---
## 2. Systemüberblick
### 2.1 Kurzbeschreibung
Die Anwendung ist eine **Einzelplatz-Stand-Alone-Desktop-Anwendung** mit **lokaler
Datenhaltung** (keine Cloud, kein Server). Die Komponente *Programmoberfläche* stellt die
**minimale grafische Benutzeroberfläche** bereit, über die die gesamte Funktionalität der
Anwendung zugänglich gemacht wird: Navigation zwischen den Modulen, Listen- und
Formularansichten der Stammdaten, Belegansichten mit Aktionen (PDF-Export, optional Druck
und E-Mail-Versand) sowie die geführte Rechnungserstellung als Schritt-für-Schritt-Dialog.
Das GUI-Framework wird gemäß dem im Project Charter dokumentierten Technologie-Stack
gewählt; dieses Pflichtenheft spezifiziert die Oberfläche framework-neutral, die
Controller-Logik jedoch bereits mit Java-Typen (Kapitel 6).
### 2.2 Abgrenzung (Was gehört dazu / was nicht)
**Im Umfang dieser Komponente:**
- Hauptfenster mit Navigation zu den Modulen Kunden, Produkte und Dokumente
- Listen-, Such- und Formularansichten für Kunden (Gruppe C) und Produkte (Gruppe B)
- Dokumentliste mit Statusanzeige und Belegaktionen (PDF-Export, Druck, E-Mail) der Gruppe A
- Geführte Rechnungserstellung als Wizard mit 5 Schritten inkl. Zusammenfassung (BA-13, UI-Sicht)
- Stornierung einer Rechnung mit Bestätigungsdialog (BA-14, UI-Sicht)
- Einheitliche Pflichtfeld-Markierung, Fehler- und Erfolgsmeldungen (Q-09)
- Startverhalten der Anwendung (Q-04)
**Nicht im Umfang dieser Komponente:**
- Fachlogik des Dokumentenzyklus: Belegerzeugung, Summenberechnung, Nummernvergabe,
Statusführung, Storno-Logik, PDF-Erzeugung (Gruppe A)
- Fachlogik und Persistenz der Stammdaten: Validierungsregeln, Nummernvergabe,
Löschsperren (Gruppen B und C)
- E-Rechnungsformate, Mahnwesen, Buchhaltung, mobile Clients (LH-Nichtziele)
### 2.3 Grobe Systemfunktionen
Anwendung starten → Hauptfenster anzeigen → Modul wählen → Liste/Suche anzeigen →
Formular oder Wizard bedienen → Eingaben an die Fachkomponenten delegieren → Ergebnis,
Fehler- oder Erfolgsmeldung anzeigen.
### 2.4 UML-Bezug
Ein gemeinsames Use-Case-Diagramm aller Gruppen gibt den Überblick über die Akteure und
Ziele. Die für Gruppe D relevanten Use Cases sind: *Rechnung erstellen (geführt)* und
*Rechnung stornieren* (jeweils Dialogführung) sowie der Bedienzugang zu allen Use Cases
der Gruppen AC. Die detaillierte logische Architektur dieser Komponente folgt in
Kapitel 7.
---
## 3. Stakeholder und Kontext
Stakeholder und Systemkontext sind im Lastenheft (§ 2, § 3) beschrieben und gelten
unverändert. Für diese Komponente ist der maßgebliche Akteur:
- **Anwender:in** — natürliche Person (Selbstständige:r, Freiberufler:in,
Kleinstunternehmer:in) **ohne technische Vorkenntnisse** (PZ-03); die Oberfläche ist
das einzige Bedienelement der Anwendung.
Angrenzende Systeme/Komponenten: intern die Komponenten Dokumentenzyklus (A),
Produktverwaltung (B) und Kundenverwaltung (C); extern — über die Dienste der Gruppe A —
Drucker und Standard-E-Mail-Client (optional).
---
## 4. Funktionale Anforderungen
Die Anforderungen sind nach Oberflächenbereichen gruppiert und mit den Satzschablonen des
Foliensatzes formuliert. Jede Anforderung ist eindeutig, vollständig, widerspruchsfrei und
verifizierbar. Alle fachlichen Operationen werden an die Komponenten AC delegiert; die
Anforderungen dieses Kapitels betreffen ausschließlich Darstellung und Dialogführung.
### 4.1 Hauptfenster und Navigation
**F-01:** Das System MUSS nach dem Programmstart ein Hauptfenster anzeigen, das eine
Navigation zu den drei Modulen *Kundenverwaltung*, *Produktverwaltung* und *Dokumente*
bereitstellt.
**F-02:** WENN die Anwender:in ein Modul auswählt, DANN MUSS das System die zugehörige
Modulansicht anzeigen, ohne dass ungespeicherte Eingaben eines Formulars unbemerkt
verloren gehen (Nachfrage bei ungespeicherten Änderungen).
### 4.2 Stammdaten-Ansichten (Kunden, Produkte)
**F-03:** Das System MUSS für die Module Kunden- und Produktverwaltung jeweils eine
sortierte Listenansicht mit einem Suchfeld anzeigen; Suchanfragen werden an die Dienste
der Gruppen C bzw. B delegiert und die Trefferliste wird innerhalb der Vorgabe aus Q-02
aktualisiert (siehe NF-PERF-02).
**F-04:** Das System MUSS für Anlegen und Ändern von Kunden und Produkten Formulare mit
allen Pflicht- und optionalen Feldern (gemäß Pflichtenheft B Kap. 6.1 und Pflichtenheft C
Kap. 6.1) anzeigen; Pflichtfelder MÜSSEN als solche gekennzeichnet sein.
**F-05:** WENN eine Fachkomponente das Speichern oder Löschen ablehnt (z. B. fehlendes
Pflichtfeld, Löschsperre GR-04), DANN MUSS das System die zurückgemeldete Fehlermeldung
sichtbar anzeigen und das betroffene Eingabefeld markieren (Q-09).
### 4.3 Dokumenten-Ansichten
**F-06:** Das System MUSS eine Dokumentliste anzeigen, die je Beleg Belegnummer, Typ,
Datum, Kunde, Bruttosumme und Status (`ENTWURF`, `OFFEN`, `VERSENDET`, `STORNIERT`)
darstellt und nach Status filterbar ist.
**F-07:** Das System MUSS je Beleg die Aktionen *PDF exportieren*, optional *Drucken* und
optional *Per E-Mail versenden* anbieten; die Ausführung wird an die Dienste der Gruppe A
delegiert.
**F-08:** WENN ein Beleg den Status `VERSENDET` oder `STORNIERT` hat, DANN MUSS das System
alle inhaltlichen Änderungsaktionen für diesen Beleg deaktivieren (GR-02; Logik bei
Gruppe A, Darstellung hier).
### 4.4 Geführte Rechnungserstellung (aus BA-13, UI-Sicht)
**F-09:** Das System MUSS die Rechnungserstellung als Dialogfolge (Wizard) mit genau fünf
Schritten anbieten: (1) Kunde auswählen, (2) mindestens eine Produktposition mit Menge
erfassen, (3) Rechnungsdatum und Zahlungsziel bestätigen, (4) Zusammenfassung prüfen,
(5) speichern.
**F-10:** WENN ein Schritt unvollständig ist (kein Kunde gewählt, keine Position erfasst,
kein Rechnungsdatum), DANN MUSS das System den Wechsel zum nächsten Schritt verhindern
und die fehlende Eingabe benennen (Q-09).
**F-11:** Das System MUSS es der Anwender:in ERMÖGLICHEN, innerhalb des Wizards zum
vorherigen Schritt zurückzukehren, ohne dass bereits erfasste Eingaben verloren gehen.
**F-12:** WENN Schritt 4 erreicht wird, DANN MUSS das System eine Zusammenfassung mit
Kunde, allen Positionen, Mengen, Netto-/Steuer-/Bruttosumme, Rechnungsdatum und
Zahlungsziel anzeigen; die Summen werden vom `DokumentService` (Gruppe A) berechnet und
hier unverändert dargestellt.
**F-13:** WENN die Anwender:in in Schritt 5 speichert, DANN MUSS das System genau einen
Speicheraufruf an den `DokumentService` (Gruppe A) auslösen und anschließend eine
Erfolgsmeldung mit der vergebenen Rechnungsnummer anzeigen.
### 4.5 Rechnung stornieren (aus BA-14, UI-Sicht)
**F-14:** Das System MUSS die Aktion *Stornieren* ausschließlich für Rechnungen im Status
`OFFEN` anbieten.
**F-15:** WENN die Anwender:in die Stornierung auslöst, DANN MUSS das System einen
Bestätigungsdialog mit Rechnungsnummer und Bruttosumme anzeigen; erst nach Bestätigung
wird die Stornierung an den `DokumentService` (Gruppe A) delegiert und das Ergebnis
(neuer Status `STORNIERT`) in der Dokumentliste dargestellt.
### 4.6 Meldungen und Eingabehilfen (übergreifend)
**F-16 (Q-09):** Das System MUSS fehlende oder ungültige Pflichtangaben in allen
Formularen einheitlich darstellen: das betroffene Feld wird optisch markiert UND die
Meldung benennt das Feld namentlich.
**F-17:** Das System MUSS nach jeder erfolgreichen Aktion (Speichern, Löschen, Export,
Storno) eine Erfolgsmeldung anzeigen und nach jeder abgelehnten Aktion die Begründung der
Fachkomponente darstellen.
---
## 5. Nicht-funktionale Anforderungen
**NF-PERF-01 (aus Q-04):** Das System MUSS nach dem Programmstart INNERHALB VON
5 SEKUNDEN vollständig bedienbereit sein (Hauptfenster sichtbar, Navigation reagiert),
bei einem Datenbestand gemäß Q-01 (bis 5.000 Kunden/Produkte).
**NF-PERF-02 (aus Q-02, UI-Anteil):** Das System MUSS Such- und Auflistungsergebnisse in
den Stammdaten-Ansichten INNERHALB VON 1 SEKUNDE nach Eingabe darstellen, bei einem
Datenbestand gemäß Q-01 (gemeinsam mit den Diensten der Gruppen B und C).
**NF-USE-01 (aus Q-05):** Die geführte Erstellung einer vollständigen Rechnung an einen
bestehenden Kunden MUSS von einer erstmaligen Anwender:in OHNE EXTERNE HILFE IN WENIGER
ALS 10 MINUTEN im ersten Versuch abgeschlossen werden können (Nachweis durch
Usability-Test mit mind. 5 Testpersonen).
**NF-USE-02 (aus Q-09):** Das System MUSS fehlende Pflichtangaben in den Formularen der
Kunden-, Produkt- und Dokumentenerstellung so markieren und benennen, dass mindestens
80 % der Testpersonen die fehlende Eingabe ohne externe Hilfe im ersten Korrekturversuch
ergänzen können (Nachweis durch Usability-Test mit mind. 5 Testpersonen).
---
## 6. Daten und Schnittstellen
Dieses Kapitel ist direkter Input für den Modultestplan (Kapitel 10). Die Komponente D
führt **keine eigenen Fachdatenobjekte**; sie arbeitet ausschließlich mit den
Datenobjekten der Gruppen AC und einem eigenen UI-Zustandsmodell. Datentypen werden
bereits als Java-Typen angegeben.
### 6.1 Datenobjekte und Datentypen (UI-Zustandsmodell)
**Designgrundsätze:**
- Die GUI hält **keinen persistenten Zustand**; alle fachlichen Daten werden über die
Service-Schnittstellen der Gruppen AC gelesen und geschrieben.
- Die Wizard-Logik (Schrittfolge, Vollständigkeitsprüfung je Schritt) ist von der
Darstellung getrennt und damit **GUI-frei testbar** (Kapitel 10).
- Beträge und Summen werden unverändert als `BigDecimal` (Scale 2) der Gruppe A
dargestellt; die GUI rechnet selbst nicht.
#### `enum WizardSchritt`
`{ KUNDE_WAEHLEN, POSITIONEN_ERFASSEN, DATEN_BESTAETIGEN, ZUSAMMENFASSUNG, SPEICHERN }`
#### Klasse `RechnungsWizardModel`
| Attribut | Java-Typ | Beschreibung |
|-----------------|----------------------------|--------------|
| aktuellerSchritt | `WizardSchritt` | aktueller Dialogschritt (F-09) |
| kundenNr | `String` (optional, `null`) | gewählter Kunde (Schritt 1) |
| positionen | `List<PositionsEingabe>` | erfasste Positionen (Schritt 2) |
| rechnungsdatum | `LocalDate` | vorbelegt mit Tagesdatum (Schritt 3) |
| zahlungsziel | `LocalDate` (optional, `null`) | leer = Standard-Zahlungsziel der Gruppe A (GR-06) |
#### Klasse `PositionsEingabe`
| Attribut | Java-Typ | Beschreibung |
|----------------|------------|--------------|
| produktnummer | `String` | gewähltes Produkt (Gruppe B) |
| menge | `int` | Stückzahl (> 0) |
#### Klasse `Meldung`
| Attribut | Java-Typ | Beschreibung |
|-----------|--------------------|--------------|
| typ | `MeldungsTyp` (`enum { ERFOLG, FEHLER }`) | Darstellung (F-16, F-17) |
| feldname | `String` (optional, `null`) | betroffenes Eingabefeld bei Validierungsfehlern |
| text | `String` | anzuzeigender Meldungstext |
### 6.2 Schnittstellen
**Externe Schnittstellen:** keine direkten — Drucker (IF-02) und E-Mail-Client (IF-03)
werden über die Dienste der Gruppe A angebunden; die GUI bietet lediglich die
auslösenden Aktionen an (F-07).
**Interne Schnittstellen (genutzte Dienste der anderen Komponenten), als Java-Interfaces
skizziert:**
```java
// Gruppe A — Dokumentenzyklus (genutzt von F-06 bis F-15)
public interface DokumentService {
Rechnung erstelleRechnung(String kundenNr, List<PositionsEingabe> positionen,
LocalDate rechnungsdatum, LocalDate zahlungsziel);
void storniere(String rechnungsnummer);
List<Dokument> alleDokumente();
void exportierePdf(String belegnummer, Path zielDatei);
}
// Gruppe C — Kundenverwaltung (genutzt von F-03, F-04, Wizard-Schritt 1)
public interface KundenService {
Kunde findeKunde(String kundennummer);
List<Kunde> suche(String suchbegriff);
}
// Gruppe B — Produktverwaltung (genutzt von F-03, F-04, Wizard-Schritt 2)
public interface ProduktService {
Produkt findeProdukt(String produktnummer);
List<Produkt> suche(String suchbegriff);
}
```
> IF-Satzschablone (Beispiel): *Das System MUSS eine Bedien-Schnittstelle bereitstellen,
> die es der Anwender:in ERMÖGLICHT, die Stornierung einer offenen Rechnung auszulösen;
> die fachliche Ausführung MUSS über `DokumentService.storniere(...)` (Gruppe A)
> erfolgen.*
---
## 7. Systemarchitektur (logisch, grob)
Die Komponente folgt dem Muster *ModelViewController*: Die Views (Hauptfenster,
Modulansichten, Wizard-Dialog) enthalten ausschließlich Darstellung; die Controller
(`HauptController`, `StammdatenController`, `RechnungsWizardController`,
`DokumentListenController`) kapseln Dialogführung und Vollständigkeitsprüfungen und rufen
die Service-Schnittstellen der Gruppen AC auf. Das UI-Zustandsmodell
(`RechnungsWizardModel`, `Meldung`) ist frei von GUI-Framework-Klassen und damit im
Modultest ohne Oberfläche prüfbar.
### 7.1 Klassendiagramm
<!-- TODO: UML-Klassendiagramm hier einfügen (Abbildung 1) -->
![Abbildung 1: UML-Klassendiagramm Programmoberfläche (Gruppe D)]
**Beschreibung zu Abbildung 1:** Das Klassendiagramm zeigt die Controller-Schicht der
Oberfläche. Der `HauptController` verwaltet die Navigation (F-01, F-02) und erzeugt die
Modul-Controller. Der `StammdatenController` bedient Listen-, Such- und Formularansichten
für Kunden und Produkte und nutzt dazu `KundenService` (Gruppe C) und `ProduktService`
(Gruppe B). Der `RechnungsWizardController` führt die Schrittfolge des Enums
`WizardSchritt` über das `RechnungsWizardModel` (Komposition) und delegiert das Speichern
an den `DokumentService` (Gruppe A). Der `DokumentListenController` stellt die
Dokumentliste mit Statusfilter dar und bietet die Belegaktionen an (F-06F-08, F-14,
F-15). Fehler- und Erfolgsmeldungen werden einheitlich über die Klasse `Meldung`
dargestellt (F-16, F-17).
### 7.2 Sequenzdiagramm
<!-- TODO: UML-Sequenzdiagramm hier einfügen (Abbildung 2) -->
![Abbildung 2: UML-Sequenzdiagramm „Geführte Rechnungserstellung (Wizard)" (Gruppe D)]
**Beschreibung zu Abbildung 2:** Das Sequenzdiagramm stellt die Dialogfolge *geführte
Rechnungserstellung* dar. Die Anwender:in startet den Wizard; der
`RechnungsWizardController` lädt in Schritt 1 die Kundenliste über
`KundenService.suche(...)` (Gruppe C) und in Schritt 2 die Produktliste über
`ProduktService.suche(...)` (Gruppe B). Vor jedem Schrittwechsel prüft der Controller die
Vollständigkeit des `RechnungsWizardModel` (F-10); bei fehlenden Eingaben wird eine
`Meldung` mit dem Feldnamen erzeugt und der Wechsel verhindert. In Schritt 4 fordert der
Controller die berechneten Summen für die Zusammenfassung an (Gruppe A) und stellt sie
unverändert dar (F-12). In Schritt 5 löst der Controller genau einen Aufruf
`DokumentService.erstelleRechnung(...)` aus (F-13), zeigt die Erfolgsmeldung mit der
vergebenen Rechnungsnummer an und schließt den Wizard.
---
## 8. Testbare Abnahmekriterien
**AC-D-01 (zu F-01, F-02, NF-PERF-01)** — *Programmstart und Navigation*
Vorbedingung: Datenbestand mit 5.000 Kunden und 5.000 Produkten (Q-01).
Aktion: Anwender:in startet die Anwendung und wechselt nacheinander in alle drei Module.
Erwartet: Das Hauptfenster ist in ≤ 5 Sekunden bedienbereit (Q-04); jede Modulansicht
wird angezeigt; bei ungespeicherten Formulareingaben erscheint eine Nachfrage.
**AC-D-02 (zu F-03, NF-PERF-02)** — *Stammdaten suchen über die Oberfläche*
Vorbedingung: Mindestens 100 Kunden und 100 Produkte sind im System.
Aktion: Anwender:in gibt in der Kunden- und der Produktansicht jeweils einen Suchbegriff
ein.
Erwartet: Die gefilterte, sortierte Trefferliste erscheint jeweils in ≤ 1 Sekunde (Q-02).
**AC-D-03 (zu F-09F-13, NF-USE-01)** — *Geführte Rechnungserstellung (Wizard)*
Vorbedingung: Mindestens ein Kunde und ein Produkt sind im System vorhanden.
Aktion: Eine erstmalige Anwender:in durchläuft den Wizard (Kunde → Position+Menge →
Datum/Zahlungsziel → Zusammenfassung → speichern).
Erwartet: Die Zusammenfassung zeigt Kunde, Position, Menge, Summen, Rechnungsdatum und
Zahlungsziel; nach dem Speichern erscheint die Erfolgsmeldung mit Rechnungsnummer; die
Durchführung gelingt ohne externe Hilfe in < 10 Minuten (Usability-Test, 5 Personen).
**AC-D-04 (zu F-10, F-16, NF-USE-02)** — *Pflichtfeldhinweis im Wizard und in Formularen*
Vorbedingung: Wizard-Schritt 1 geöffnet bzw. Formulare „Kunde anlegen" und „Produkt
anlegen" erreichbar.
Aktion: Testpersonen versuchen ohne Kundenauswahl in Schritt 2 zu wechseln bzw. ohne ein
Pflichtfeld zu speichern; anschließend ergänzen sie die fehlende Angabe.
Erwartet: Der Wechsel bzw. das Speichern wird zuerst verhindert, das fehlende Feld wird
markiert und benannt; in ≥ 80 % der Testdurchläufe gelingt die Korrektur ohne externe
Hilfe im ersten Versuch.
**AC-D-05 (zu F-14, F-15)** — *Stornierung mit Bestätigungsdialog*
Vorbedingung: Eine Rechnung im Status `OFFEN` und eine im Status `VERSENDET` existieren.
Aktion: Anwender:in öffnet die Dokumentliste, prüft die angebotenen Aktionen und
storniert die offene Rechnung nach Bestätigung.
Erwartet: *Stornieren* wird nur für die offene Rechnung angeboten; der
Bestätigungsdialog zeigt Rechnungsnummer und Bruttosumme; nach Bestätigung erscheint die
Rechnung mit Status `STORNIERT` in der Liste.
**AC-D-06 (zu F-06F-08)** — *Dokumentliste, Statusfilter, deaktivierte Aktionen*
Vorbedingung: Belege in den Status `ENTWURF`, `OFFEN`, `VERSENDET`, `STORNIERT` existieren.
Aktion: Anwender:in filtert die Dokumentliste nach Status und öffnet einen versendeten
Beleg.
Erwartet: Der Filter zeigt ausschließlich Belege des gewählten Status; für den
versendeten Beleg sind alle inhaltlichen Änderungsaktionen deaktiviert, PDF-Export bleibt
verfügbar.
---
## 9. Traceability LH ↔ PH
Jede für Gruppe D relevante Lastenheft-Anforderung ist mindestens einer
Pflichtenheft-Anforderung zugeordnet.
| LH-Anforderung | Beschreibung (LH) | PH-Anforderung(en) |
|----------------|-------------------------------------------|---------------------------|
| BA-13 | Geführte Rechnungserstellung (UI-Anteil) | F-09, F-10, F-11, F-12, F-13 |
| BA-14 | Rechnung stornieren (UI-Anteil) | F-14, F-15 |
| BA-01BA-08 | Stammdatenpflege (Bedienzugang) | F-03, F-04, F-05 |
| BA-09BA-12 | Belegerstellung (Bedienzugang) | F-06, F-07 |
| GR-02 | Unveränderlichkeit versendeter Dokumente | F-08 (Darstellung) |
| PZ-03 | Bedienbarkeit ohne Vorkenntnisse | F-09F-13, NF-USE-01 |
| Q-02 | Suche/Auflistung ≤ 1 s (UI-Anteil) | NF-PERF-02, F-03 |
| Q-04 | Anwendungsstart ≤ 5 s | NF-PERF-01 |
| Q-05 | Usability Ersterstellung Rechnung | NF-USE-01 |
| Q-09 | Pflichtfeldhinweise ≥ 80 % | NF-USE-02, F-10, F-16 |
> Hinweis: Die Fachlogik zu BA-13/BA-14 (Schrittvalidierung beim Speichern,
> Statuswechsel, Protokollierung) ist im Pflichtenheft der Gruppe A spezifiziert
> (F-16F-21 dort); dieses Dokument spezifiziert ausschließlich Dialogführung und
> Darstellung. Die fachlichen Validierungs- und Performanceregeln der Stammdatenmodule
> liegen bei den Gruppen B und C.
---
## 10. Modultestplan
Die folgenden Testfälle sind deterministisch (feste Ein-/Ausgaben) und mit JUnit 5
umsetzbar. Getestet wird die GUI-freie Controller- und Modell-Schicht (Kapitel 6.1/7);
die Service-Schnittstellen der Gruppen AC werden durch Stubs/Mocks ersetzt. Die
Usability-Nachweise (NF-USE-01/02) erfolgen ergänzend durch manuelle Usability-Tests
(Kapitel 8) und sind nicht Teil des automatisierten Modultests.
| TC | Abgedeckte PH-Anf. | Vorbedingung | Eingabe | Erwartetes Ergebnis |
|-------|--------------------|--------------|---------|---------------------|
| TC-01 | F-09 | Wizard neu gestartet | `aktuellerSchritt` lesen | `KUNDE_WAEHLEN` (erster Schritt) |
| TC-02 | F-09 | Schritt 1 mit gewähltem Kunden | `weiter()` 4-mal mit gültigen Eingaben | Schrittfolge: `POSITIONEN_ERFASSEN``DATEN_BESTAETIGEN``ZUSAMMENFASSUNG``SPEICHERN` |
| TC-03 | F-10 | Schritt 1, kein Kunde gewählt (`kundenNr = null`) | `weiter()` | Wechsel verhindert; `Meldung(FEHLER, "Kunde", …)` erzeugt |
| TC-04 | F-10 | Schritt 2, leere Positionsliste | `weiter()` | Wechsel verhindert; Meldung benennt „Position" |
| TC-05 | F-10 | Schritt 2, Position mit `menge = 0` | `weiter()` | Wechsel verhindert; Meldung benennt „Menge" |
| TC-06 | F-11 | Schritt 3 erreicht; Kunde `K-000017`, 1 Position erfasst | `zurueck()` bis Schritt 1 | `kundenNr` und `positionen` unverändert erhalten |
| TC-07 | F-12 | Schritt 4; Stub `DokumentService` liefert Summen 200.00/38.00/238.00 | Zusammenfassung erzeugen | Zusammenfassung enthält Kunde, Positionen, Mengen, 200.00/38.00/238.00, Rechnungsdatum, Zahlungsziel |
| TC-08 | F-13 | Schritt 5; gültiges Modell | `speichern()` | genau **ein** Aufruf `erstelleRechnung(...)` am Mock; Erfolgsmeldung enthält gelieferte Rechnungsnummer |
| TC-09 | F-13 (Fehlerfall) | Stub `erstelleRechnung` wirft Validierungsfehler „Rechnungsdatum" | `speichern()` | keine Erfolgsmeldung; `Meldung(FEHLER, "Rechnungsdatum", …)` dargestellt (F-05/F-16) |
| TC-10 | F-14 | Dokumentliste mit Rechnungen in `OFFEN`, `VERSENDET`, `STORNIERT` | verfügbare Aktionen je Rechnung ermitteln | *Stornieren* nur bei Status `OFFEN` aktiviert |
| TC-11 | F-15 | Rechnung `R-2026-000124` im Status `OFFEN` | `storniere()` ohne Bestätigung; danach mit Bestätigung | ohne Bestätigung: kein Service-Aufruf; mit Bestätigung: genau ein Aufruf `storniere("R-2026-000124")` |
| TC-12 | F-08 | Beleg im Status `VERSENDET` | Änderungsaktionen ermitteln | alle inhaltlichen Änderungsaktionen deaktiviert; PDF-Export aktiviert |
| TC-13 | F-06 | Belege mit Status `OFFEN` (2×) und `STORNIERT` (1×) | Statusfilter `OFFEN` anwenden | Liste enthält genau die 2 offenen Belege |
| TC-14 | F-03 | Stub `KundenService.suche("Muster")` liefert 1 Treffer | Suchbegriff „Muster" eingeben | Controller delegiert an `KundenService.suche(...)`; Trefferliste enthält genau diesen Kunden |
Damit sind 14 Testfälle (> 10) spezifiziert, die alle funktionalen Kernregeln der
Dialogführung (F-09F-15) sowie die übergreifenden Darstellungsregeln (F-03, F-06, F-08,
F-16) abdecken.
---
## 11. Anhänge
### 11.1 Abkürzungen
| Abkürzung | Bedeutung |
|-----------|-----------|
| F | Funktionale Anforderung (Pflichtenheft) |
| NF | Nicht-funktionale Anforderung (Pflichtenheft) |
| IF | Schnittstelle (Interface) |
| AC | Abnahmekriterium |
| TC | Testfall (Test Case) |
| BA | Benutzeranforderung (Lastenheft) |
| GR | Geschäftsregel (Lastenheft) |
| Q | Qualitätsanforderung (Lastenheft) |
| PZ | Projektziel (Lastenheft) |
| GUI | Graphical User Interface (grafische Benutzeroberfläche) |
| MVC | ModelViewController (Architekturmuster) |
| SRS | System Requirements Specification (Pflichtenheft) |
### 11.2 Glossar
Es gilt das Glossar des Lastenhefts (§ 8.1) unverändert.
### 11.3 Referenzen
Siehe Kapitel 1.5.

Binary file not shown.

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.team1</groupId>
<artifactId>fakturierung</artifactId>
<name>Desktop-Fakturierungsanwendung (Team 1)</name>
<version>1.0.0</version>
<description>Einzelplatz-Fakturierungsanwendung gemäß Lastenheft v1.3 und den
Pflichtenheften der Gruppen A (Dokumentenzyklus), B (Produkte),
C (Kunden) und D (Programmoberfläche).</description>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<archive>
<manifest>
<mainClass>de.team1.faktura.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>de.team1.faktura.Main</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>junit-jupiter-api</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-params</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<pdfbox.version>3.0.3</pdfbox.version>
<junit.version>5.10.2</junit.version>
<jackson.version>2.17.2</jackson.version>
</properties>
</project>

21
mvnw vendored 100644
View File

@ -0,0 +1,21 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# Maven-Bootstrap-Skript (Unix/Git-Bash): laedt Apache Maven beim ersten
# Aufruf nach ~/.m2/wrapper und ruft es mit allen Argumenten auf.
# ---------------------------------------------------------------------------
set -e
MAVEN_VERSION=3.9.9
WRAPPER_DIR="$HOME/.m2/wrapper/dists"
MAVEN_HOME="$WRAPPER_DIR/apache-maven-$MAVEN_VERSION"
if [ ! -x "$MAVEN_HOME/bin/mvn" ]; then
echo "Lade Apache Maven $MAVEN_VERSION herunter (einmalig)..."
mkdir -p "$WRAPPER_DIR"
ZIP="$WRAPPER_DIR/apache-maven-$MAVEN_VERSION-bin.zip"
curl -fsSL -o "$ZIP" "https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/$MAVEN_VERSION/apache-maven-$MAVEN_VERSION-bin.zip"
unzip -q -o "$ZIP" -d "$WRAPPER_DIR"
rm -f "$ZIP"
fi
exec "$MAVEN_HOME/bin/mvn" "$@"

31
mvnw.cmd vendored 100644
View File

@ -0,0 +1,31 @@
@echo off
rem ---------------------------------------------------------------------------
rem Maven-Bootstrap-Skript (Windows): laedt Apache Maven beim ersten Aufruf
rem nach %USERPROFILE%\.m2\wrapper und ruft es anschliessend mit allen
rem uebergebenen Argumenten auf. Es ist keine lokale Maven-Installation noetig.
rem ---------------------------------------------------------------------------
setlocal
set "MAVEN_VERSION=3.9.9"
set "WRAPPER_DIR=%USERPROFILE%\.m2\wrapper\dists"
set "MAVEN_HOME=%WRAPPER_DIR%\apache-maven-%MAVEN_VERSION%"
if exist "%MAVEN_HOME%\bin\mvn.cmd" goto run
echo Lade Apache Maven %MAVEN_VERSION% herunter (einmalig)...
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$ErrorActionPreference = 'Stop';" ^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12;" ^
"New-Item -ItemType Directory -Force '%WRAPPER_DIR%' | Out-Null;" ^
"$zip = Join-Path '%WRAPPER_DIR%' 'apache-maven-%MAVEN_VERSION%-bin.zip';" ^
"Invoke-WebRequest -Uri 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/%MAVEN_VERSION%/apache-maven-%MAVEN_VERSION%-bin.zip' -OutFile $zip;" ^
"Expand-Archive -Path $zip -DestinationPath '%WRAPPER_DIR%' -Force;" ^
"Remove-Item $zip"
if errorlevel 1 (
echo FEHLER: Maven konnte nicht heruntergeladen werden.
exit /b 1
)
:run
"%MAVEN_HOME%\bin\mvn.cmd" %*
exit /b %ERRORLEVEL%

108
pom.xml 100644
View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.team1</groupId>
<artifactId>fakturierung</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Desktop-Fakturierungsanwendung (Team 1)</name>
<description>
Einzelplatz-Fakturierungsanwendung gemäß Lastenheft v1.3 und den
Pflichtenheften der Gruppen A (Dokumentenzyklus), B (Produkte),
C (Kunden) und D (Programmoberfläche).
</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<jackson.version>2.17.2</jackson.version>
<junit.version>5.10.2</junit.version>
<pdfbox.version>3.0.3</pdfbox.version>
</properties>
<dependencies>
<!-- Persistenz: JSON-Dateien (IF-01) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- PDF-Export der Belege (Gruppe A, F-04/F-07/F-10/F-15) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<!-- Modultests (JUnit 5, Kapitel 10 der Pflichtenhefte) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<archive>
<manifest>
<mainClass>de.team1.faktura.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<!-- Ausführbares Fat-JAR: java -jar target/fakturierung-1.0.0.jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>de.team1.faktura.Main</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,85 @@
package de.team1.faktura;
import de.team1.faktura.dokumente.DokumentReferenzPruefung;
import de.team1.faktura.dokumente.DokumentService;
import de.team1.faktura.dokumente.EinfacherBelegnummernGenerator;
import de.team1.faktura.dokumente.JsonDokumentRepository;
import de.team1.faktura.dokumente.PdfBoxPdfExporter;
import de.team1.faktura.dokumente.StandardDokumentService;
import de.team1.faktura.gui.DokumentListenPanel;
import de.team1.faktura.gui.HauptFenster;
import de.team1.faktura.gui.KundenPanel;
import de.team1.faktura.gui.ProduktPanel;
import de.team1.faktura.kunden.EinfacherKundennummernGenerator;
import de.team1.faktura.kunden.JsonKundenRepository;
import de.team1.faktura.kunden.KundenCsvExport;
import de.team1.faktura.kunden.KundenVerwaltungsService;
import de.team1.faktura.produkte.EinfacherProduktnummernGenerator;
import de.team1.faktura.produkte.JsonProduktRepository;
import de.team1.faktura.produkte.ProduktCsvExport;
import de.team1.faktura.produkte.ProduktVerwaltungsService;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import java.nio.file.Path;
/**
* Einstiegspunkt der Desktop-Fakturierungsanwendung: verdrahtet die vier
* Komponenten (A: Dokumentenzyklus, B: Produkte, C: Kunden, D: Oberfläche)
* und startet die GUI. Alle Daten liegen ausschließlich lokal im
* Verzeichnis {@code daten/} (Q-06, IF-01).
*/
public final class Main {
private Main() {
}
public static void main(String[] args) {
Path datenVerzeichnis = Path.of("daten");
// Persistenz (IF-01)
JsonKundenRepository kundenRepository =
new JsonKundenRepository(datenVerzeichnis.resolve("kunden.json"));
JsonProduktRepository produktRepository =
new JsonProduktRepository(datenVerzeichnis.resolve("produkte.json"));
JsonDokumentRepository dokumentRepository =
new JsonDokumentRepository(datenVerzeichnis.resolve("dokumente.json"));
// Gruppe A stellt die Referenzprüfungen für die Löschsperren bereit
DokumentReferenzPruefung referenzPruefung = new DokumentReferenzPruefung(dokumentRepository);
// Gruppe C — Kundenverwaltung
KundenVerwaltungsService kundenService = new KundenVerwaltungsService(
kundenRepository,
EinfacherKundennummernGenerator.ausRepository(kundenRepository),
referenzPruefung);
// Gruppe B — Produktverwaltung
ProduktVerwaltungsService produktService = new ProduktVerwaltungsService(
produktRepository,
EinfacherProduktnummernGenerator.ausRepository(produktRepository),
referenzPruefung);
// Gruppe A — Dokumentenzyklus
DokumentService dokumentService = new StandardDokumentService(
dokumentRepository,
EinfacherBelegnummernGenerator.ausRepository(dokumentRepository),
kundenService,
produktService,
new PdfBoxPdfExporter());
// Gruppe D — Programmoberfläche
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception e) {
// Standard-Look-and-Feel verwenden
}
HauptFenster fenster = new HauptFenster(
new KundenPanel(kundenService, new KundenCsvExport(kundenRepository)),
new ProduktPanel(produktService, new ProduktCsvExport(produktRepository)),
new DokumentListenPanel(dokumentService, kundenService, produktService));
fenster.setVisible(true);
});
}
}

View File

@ -0,0 +1,25 @@
package de.team1.faktura.dokumente;
import java.time.LocalDate;
/**
* Angebot (BA-09, A-F-01 bis F-04): Beleg mit Gültigkeitsdatum.
*/
public class Angebot extends Dokument {
private LocalDate gueltigBis;
@Override
public Belegtyp belegtyp() {
return Belegtyp.ANGEBOT;
}
public LocalDate getGueltigBis() {
return gueltigBis;
}
public void setGueltigBis(LocalDate gueltigBis) {
pruefeAenderbar();
this.gueltigBis = gueltigBis;
}
}

View File

@ -0,0 +1,13 @@
package de.team1.faktura.dokumente;
/**
* Auftragsbestätigung (BA-10, A-F-05 bis F-07): nutzt die Rückreferenz
* {@code vorgaengerNr} auf das zugrunde liegende Angebot (GR-05).
*/
public class Auftragsbestaetigung extends Dokument {
@Override
public Belegtyp belegtyp() {
return Belegtyp.AUFTRAGSBESTAETIGUNG;
}
}

View File

@ -0,0 +1,13 @@
package de.team1.faktura.dokumente;
/**
* Vergabe eindeutiger Belegnummern je Belegtyp (GR-01, A Kapitel 6.2).
*/
public interface BelegnummernGenerator {
/**
* Liefert die nächste fortlaufende, lückenlose Nummer für den Belegtyp,
* z. B. {@code "R-2026-000124"} (Präfix, Jahr, führende Nullen).
*/
String naechsteNummer(Belegtyp typ, int jahr);
}

View File

@ -0,0 +1,28 @@
package de.team1.faktura.dokumente;
/**
* Die vier kaufmännischen Belegtypen mit ihren Nummern-Präfixen
* (Gruppe A, Kapitel 4: AN-, AB-, LS-, R-).
*/
public enum Belegtyp {
ANGEBOT("AN", "Angebot"),
AUFTRAGSBESTAETIGUNG("AB", "Auftragsbestätigung"),
LIEFERSCHEIN("LS", "Lieferschein"),
RECHNUNG("R", "Rechnung");
private final String praefix;
private final String anzeigename;
Belegtyp(String praefix, String anzeigename) {
this.praefix = praefix;
this.anzeigename = anzeigename;
}
public String praefix() {
return praefix;
}
public String anzeigename() {
return anzeigename;
}
}

View File

@ -0,0 +1,167 @@
package de.team1.faktura.dokumente;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
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 Oberklasse aller Belege (Gruppe A, Kapitel 6.1).
*
* <p>Neben der Kundenreferenz werden Name und Anschrift des Kunden als
* Snapshot zum Erstellzeitpunkt abgelegt, damit bereits erstellte Belege
* von späteren Stammdatenänderungen unberührt bleiben (C-F-06, AC-C-03).
*
* <p>Belege im Status {@code VERSENDET} oder {@code STORNIERT} lehnen jede
* inhaltliche Änderung mit {@link IllegalStateException} ab (F-24, GR-02).
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "typ")
@JsonSubTypes({
@JsonSubTypes.Type(value = Angebot.class, name = "ANGEBOT"),
@JsonSubTypes.Type(value = Auftragsbestaetigung.class, name = "AUFTRAGSBESTAETIGUNG"),
@JsonSubTypes.Type(value = Lieferschein.class, name = "LIEFERSCHEIN"),
@JsonSubTypes.Type(value = Rechnung.class, name = "RECHNUNG")
})
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE,
setterVisibility = JsonAutoDetect.Visibility.NONE)
public abstract class Dokument {
private String belegnummer;
private LocalDate datum;
private String kundenReferenz;
private String kundeName;
private String kundeAnschrift;
private List<Dokumentposition> positionen = new ArrayList<>();
private DokumentStatus status = DokumentStatus.ENTWURF;
private String vorgaengerNr;
private BigDecimal summeNetto = BigDecimal.ZERO.setScale(2);
private BigDecimal summeSteuer = BigDecimal.ZERO.setScale(2);
private BigDecimal summeBrutto = BigDecimal.ZERO.setScale(2);
public abstract Belegtyp belegtyp();
/** Lehnt inhaltliche Änderungen versendeter/stornierter Belege ab (F-24, F-21, GR-02). */
public void pruefeAenderbar() {
if (status == DokumentStatus.VERSENDET || status == DokumentStatus.STORNIERT) {
throw new IllegalStateException(
"Der Beleg " + belegnummer + " ist im Status " + status
+ " und darf inhaltlich nicht mehr geändert werden (GR-02).");
}
}
/** Ersetzt die Positionen und berechnet die Summen neu (F-23). */
public void setzePositionen(List<Dokumentposition> neuePositionen) {
pruefeAenderbar();
this.positionen = new ArrayList<>(neuePositionen);
berechneSummen();
}
/** Netto-, Steuer- und Bruttosumme aus den Positionen, Scale 2 (F-03, F-23, TC-03). */
public void berechneSummen() {
BigDecimal netto = BigDecimal.ZERO;
BigDecimal steuer = BigDecimal.ZERO;
for (Dokumentposition position : positionen) {
netto = netto.add(position.getPositionssummeNetto());
steuer = steuer.add(position.getSteuerbetrag());
}
this.summeNetto = netto.setScale(2, RoundingMode.HALF_UP);
this.summeSteuer = steuer.setScale(2, RoundingMode.HALF_UP);
this.summeBrutto = this.summeNetto.add(this.summeSteuer);
}
/** Statuswechsel auf {@code VERSENDET}; danach greift die Unveränderlichkeit (GR-02). */
public void versende() {
if (status == DokumentStatus.VERSENDET) {
return;
}
if (status == DokumentStatus.STORNIERT) {
throw new IllegalStateException(
"Ein stornierter Beleg kann nicht versendet werden.");
}
status = DokumentStatus.VERSENDET;
}
public String getBelegnummer() {
return belegnummer;
}
/** Einmalige Vergabe durch das System (Kapitel 4, Belegnummern-Regel). */
public void setBelegnummer(String belegnummer) {
if (this.belegnummer != null && !this.belegnummer.equals(belegnummer)) {
throw new IllegalArgumentException(
"Die Belegnummer ist nach der Vergabe unveränderlich.");
}
this.belegnummer = belegnummer;
}
public LocalDate getDatum() {
return datum;
}
public void setDatum(LocalDate datum) {
pruefeAenderbar();
this.datum = datum;
}
public String getKundenReferenz() {
return kundenReferenz;
}
public String getKundeName() {
return kundeName;
}
public String getKundeAnschrift() {
return kundeAnschrift;
}
/** Übernimmt die Kundendaten als Snapshot zum Erstellzeitpunkt (GR-05, AC-C-03). */
public void setzeKunde(String kundenReferenz, String kundeName, String kundeAnschrift) {
pruefeAenderbar();
this.kundenReferenz = kundenReferenz;
this.kundeName = kundeName;
this.kundeAnschrift = kundeAnschrift;
}
public List<Dokumentposition> getPositionen() {
return Collections.unmodifiableList(positionen);
}
public DokumentStatus getStatus() {
return status;
}
protected void setzeStatus(DokumentStatus status) {
this.status = status;
}
public String getVorgaengerNr() {
return vorgaengerNr;
}
/** Rückreferenz auf den Vorgängerbeleg im Dokumentenzyklus (GR-05, F-22). */
public void setVorgaengerNr(String vorgaengerNr) {
pruefeAenderbar();
this.vorgaengerNr = vorgaengerNr;
}
public BigDecimal getSummeNetto() {
return summeNetto;
}
public BigDecimal getSummeSteuer() {
return summeSteuer;
}
public BigDecimal getSummeBrutto() {
return summeBrutto;
}
}

View File

@ -0,0 +1,31 @@
package de.team1.faktura.dokumente;
import de.team1.faktura.kunden.KundenReferenzPruefung;
import de.team1.faktura.produkte.ProduktReferenzPruefung;
/**
* Von Gruppe A bereitgestellte Referenzprüfungen für die Löschsperren der
* Stammdatenmodule: GR-04 (Kunden, C-F-10) und B-F-10 (Produkte).
*/
public class DokumentReferenzPruefung implements KundenReferenzPruefung, ProduktReferenzPruefung {
private final DokumentRepository repository;
public DokumentReferenzPruefung(DokumentRepository repository) {
this.repository = repository;
}
@Override
public int anzahlVerknuepfterDokumente(String kundennummer) {
return (int) repository.alle().stream()
.filter(d -> kundennummer.equals(d.getKundenReferenz()))
.count();
}
@Override
public boolean istProduktReferenziert(String produktnummer) {
return repository.alle().stream()
.flatMap(d -> d.getPositionen().stream())
.anyMatch(p -> produktnummer.equals(p.getProduktReferenz()));
}
}

View File

@ -0,0 +1,17 @@
package de.team1.faktura.dokumente;
import java.util.List;
/**
* Persistenz der Belege im lokalen Dateisystem (IF-01, A Kapitel 7).
* Belege werden nie gelöscht (GoBD: lückenlose Erfassung).
*/
public interface DokumentRepository {
Dokument speichere(Dokument dokument);
/** Liefert den Beleg zur Belegnummer oder {@code null}. */
Dokument findeNachNummer(String belegnummer);
List<Dokument> alle();
}

View File

@ -0,0 +1,54 @@
package de.team1.faktura.dokumente;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
/**
* Zentrale Fachlogik des Dokumentenzyklus (Pflichtenheft Gruppe A,
* Kapitel 7): Belegerzeugung, Summenberechnung, Nummernvergabe,
* Verknüpfung, Statusführung, Stornierung und PDF-Export. Wird von der
* Programmoberfläche (Gruppe D) über diese Schnittstelle genutzt.
*/
public interface DokumentService {
/** Erstellt ein Angebot (F-01, F-02); {@code gueltigBis = null} → Datum + 30 Tage. */
Angebot erstelleAngebot(String kundenNr, List<Positionsangabe> positionen, LocalDate gueltigBis);
/** Erstellt eine Auftragsbestätigung ohne Vorgängerbeleg (F-05, F-06). */
Auftragsbestaetigung erstelleAuftragsbestaetigung(String kundenNr, List<Positionsangabe> positionen);
/** Erstellt einen Lieferschein ohne Vorgängerbeleg (F-08, F-09). */
Lieferschein erstelleLieferschein(String kundenNr, List<Positionsangabe> positionen, LocalDate lieferdatum);
/**
* Erstellt eine Rechnung (F-11 bis F-15); {@code zahlungsziel = null}
* Standard-Zahlungsziel 14 Kalendertage ab Rechnungsdatum (GR-06).
*/
Rechnung erstelleRechnung(String kundenNr, List<Positionsangabe> positionen,
LocalDate rechnungsdatum, LocalDate zahlungsziel);
/**
* Erzeugt den Folgebeleg im Dokumentenzyklus (GR-05, F-22):
* Angebot Auftragsbestätigung Lieferschein Rechnung. Kunde,
* Positionen und Mengen werden übernommen, die Rückreferenz gespeichert.
*/
Dokument erzeugeFolgebeleg(String belegnummer);
/** Setzt den Belegstatus auf {@code VERSENDET}; danach gilt GR-02. */
void versende(String belegnummer);
/** Storniert eine offene Rechnung (F-19, F-20). */
void storniere(String rechnungsnummer);
List<Dokument> alleDokumente();
/** Alle Rechnungen im Status {@code OFFEN} (F-20). */
List<Rechnung> offeneRechnungen();
/** Berechnet die Summen für die Wizard-Zusammenfassung (D-F-12), ohne zu speichern. */
Summen berechneSummen(List<Positionsangabe> positionen);
/** Exportiert den Beleg als PDF in das lokale Dateisystem (F-04, F-07, F-10, F-15). */
void exportierePdf(String belegnummer, Path zielDatei);
}

View File

@ -0,0 +1,11 @@
package de.team1.faktura.dokumente;
/**
* Lebenszyklus-Status eines Belegs (Pflichtenheft Gruppe A, Kapitel 6.1).
*/
public enum DokumentStatus {
ENTWURF,
OFFEN,
VERSENDET,
STORNIERT
}

View File

@ -0,0 +1,79 @@
package de.team1.faktura.dokumente;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* Position eines Belegs (Gruppe A, Kapitel 6.1). Bezeichnung, Einzelpreis
* und Steuersatz sind ein unveränderlicher <b>Snapshot</b> des Produkts zum
* Erstellzeitpunkt (GR-03, F-23). Beträge: {@code BigDecimal}, Scale 2,
* kaufmännische Rundung.
*/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE,
setterVisibility = JsonAutoDetect.Visibility.NONE)
public class Dokumentposition {
private String produktReferenz;
private String bezeichnung;
private int menge;
private BigDecimal einzelpreisNetto;
private BigDecimal steuersatz;
private BigDecimal positionssummeNetto;
public Dokumentposition() {
}
public Dokumentposition(String produktReferenz, String bezeichnung, int menge,
BigDecimal einzelpreisNetto, BigDecimal steuersatz) {
this.produktReferenz = produktReferenz;
this.bezeichnung = bezeichnung;
this.menge = menge;
this.einzelpreisNetto = einzelpreisNetto.setScale(2, RoundingMode.HALF_UP);
this.steuersatz = steuersatz;
this.positionssummeNetto = berechnePositionssummeNetto();
}
/** {@code einzelpreisNetto * menge}, Scale 2 (F-23, TC-02). */
private BigDecimal berechnePositionssummeNetto() {
return einzelpreisNetto.multiply(BigDecimal.valueOf(menge))
.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal getPositionssummeNetto() {
return positionssummeNetto;
}
/** Steuerbetrag der Position: {@code positionssummeNetto * steuersatz}, Scale 2 (F-23, TC-01). */
public BigDecimal getSteuerbetrag() {
return positionssummeNetto.multiply(steuersatz).setScale(2, RoundingMode.HALF_UP);
}
/** Bruttobetrag der Position: Netto + Steuer (TC-01). */
public BigDecimal getPositionssummeBrutto() {
return positionssummeNetto.add(getSteuerbetrag());
}
public String getProduktReferenz() {
return produktReferenz;
}
public String getBezeichnung() {
return bezeichnung;
}
public int getMenge() {
return menge;
}
public BigDecimal getEinzelpreisNetto() {
return einzelpreisNetto;
}
public BigDecimal getSteuersatz() {
return steuersatz;
}
}

View File

@ -0,0 +1,49 @@
package de.team1.faktura.dokumente;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Belegnummern im Format {@code <PRÄFIX>-<JAHR>-NNNNNN}. Je Belegtyp und
* Jahr wird ein eigener fortlaufender Zähler auf Basis der höchsten bisher
* vergebenen Nummer geführt; Rechnungsnummern sind damit lückenlos, da
* Belege nie gelöscht werden (GR-01, F-12).
*/
public class EinfacherBelegnummernGenerator implements BelegnummernGenerator {
private static final Pattern FORMAT = Pattern.compile("(AN|AB|LS|R)-(\\d{4})-(\\d{6})");
private final Map<String, Integer> zaehler = new HashMap<>();
public EinfacherBelegnummernGenerator() {
}
/** Initialisiert die Zähler aus den höchsten bereits vergebenen Nummern im Bestand. */
public static EinfacherBelegnummernGenerator ausRepository(DokumentRepository repository) {
EinfacherBelegnummernGenerator generator = new EinfacherBelegnummernGenerator();
for (Dokument dokument : repository.alle()) {
Matcher matcher = FORMAT.matcher(dokument.getBelegnummer());
if (matcher.matches()) {
String schluessel = matcher.group(1) + "-" + matcher.group(2);
int wert = Integer.parseInt(matcher.group(3)) + 1;
generator.zaehler.merge(schluessel, wert, Math::max);
}
}
return generator;
}
/** Setzt den Zähler explizit, z. B. {@code setzeZaehler(RECHNUNG, 2026, 7)} → {@code R-2026-000007}. */
public void setzeZaehler(Belegtyp typ, int jahr, int wert) {
zaehler.put(typ.praefix() + "-" + jahr, wert);
}
@Override
public synchronized String naechsteNummer(Belegtyp typ, int jahr) {
String schluessel = typ.praefix() + "-" + jahr;
int naechste = zaehler.getOrDefault(schluessel, 1);
zaehler.put(schluessel, naechste + 1);
return String.format("%s-%04d-%06d", typ.praefix(), jahr, naechste);
}
}

View File

@ -0,0 +1,74 @@
package de.team1.faktura.dokumente;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.team1.faktura.gemeinsam.JsonPersistenz;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* JSON-Datei-Persistenz der Belege (IF-01). Die Polymorphie der Belegtypen
* wird über das {@code typ}-Feld abgebildet (siehe {@link Dokument}).
*/
public class JsonDokumentRepository implements DokumentRepository {
private final Path datei;
private final ObjectMapper mapper = JsonPersistenz.mapper();
private final List<Dokument> dokumente = new ArrayList<>();
public JsonDokumentRepository(Path datei) {
this.datei = datei;
lade();
}
private void lade() {
if (!Files.exists(datei)) {
return;
}
try {
dokumente.addAll(mapper.readValue(datei.toFile(), new TypeReference<List<Dokument>>() { }));
} catch (IOException e) {
throw new UncheckedIOException("Belegbestand konnte nicht gelesen werden: " + datei, e);
}
}
private void schreibe() {
try {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
mapper.writeValue(datei.toFile(), dokumente);
} catch (IOException e) {
throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e);
}
}
@Override
public Dokument speichere(Dokument dokument) {
dokumente.removeIf(d -> d.getBelegnummer().equals(dokument.getBelegnummer()));
dokumente.add(dokument);
schreibe();
return dokument;
}
@Override
public Dokument findeNachNummer(String belegnummer) {
return dokumente.stream()
.filter(d -> d.getBelegnummer().equals(belegnummer))
.findFirst()
.orElse(null);
}
@Override
public List<Dokument> alle() {
return dokumente.stream()
.sorted(Comparator.comparing(Dokument::getBelegnummer))
.toList();
}
}

View File

@ -0,0 +1,25 @@
package de.team1.faktura.dokumente;
import java.time.LocalDate;
/**
* Lieferschein (BA-11, A-F-08 bis F-10): Beleg mit Lieferdatum.
*/
public class Lieferschein extends Dokument {
private LocalDate lieferdatum;
@Override
public Belegtyp belegtyp() {
return Belegtyp.LIEFERSCHEIN;
}
public LocalDate getLieferdatum() {
return lieferdatum;
}
public void setLieferdatum(LocalDate lieferdatum) {
pruefeAenderbar();
this.lieferdatum = lieferdatum;
}
}

View File

@ -0,0 +1,152 @@
package de.team1.faktura.dokumente;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.format.DateTimeFormatter;
/**
* PDF-Export der Belege mit Apache PDFBox (A-F-04, F-07, F-10, F-15).
* Rechnungen enthalten die Pflichtangaben gemäß § 14 UStG (F-13):
* Belegnummer, Rechnungs- und Leistungsdatum, Kundendaten, Positionen mit
* Einzel- und Gesamtbeträgen, Steuersatz/-betrag sowie die Summen.
*/
public class PdfBoxPdfExporter implements PdfExporter {
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final float RAND = 50;
private static final float ZEILENHOEHE = 14;
private final PDFont normal = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
private final PDFont fett = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
@Override
public void exportiere(Dokument dokument, Path zielDatei) {
try (PDDocument pdf = new PDDocument()) {
Schreiber schreiber = new Schreiber(pdf);
schreiber.zeile(fett, 16, dokument.belegtyp().anzeigename() + " " + dokument.getBelegnummer());
if (dokument.getStatus() == DokumentStatus.STORNIERT) {
schreiber.zeile(fett, 12, "*** STORNIERT ***");
}
schreiber.leer();
schreiber.zeile(normal, 10, "Datum: " + format(dokument.getDatum()));
if (dokument instanceof Angebot angebot) {
schreiber.zeile(normal, 10, "Gültig bis: " + format(angebot.getGueltigBis()));
}
if (dokument instanceof Lieferschein lieferschein) {
schreiber.zeile(normal, 10, "Lieferdatum: " + format(lieferschein.getLieferdatum()));
}
if (dokument instanceof Rechnung rechnung) {
schreiber.zeile(normal, 10, "Leistungsdatum: " + format(rechnung.getLeistungsdatum()));
schreiber.zeile(normal, 10, "Zahlbar bis: " + format(rechnung.getZahlungsziel()));
}
if (dokument.getVorgaengerNr() != null) {
schreiber.zeile(normal, 10, "Referenz: " + dokument.getVorgaengerNr());
}
schreiber.leer();
schreiber.zeile(fett, 10, "Kunde");
schreiber.zeile(normal, 10, dokument.getKundeName() + " (" + dokument.getKundenReferenz() + ")");
schreiber.zeile(normal, 10, dokument.getKundeAnschrift());
schreiber.leer();
schreiber.zeile(fett, 10, String.format("%-4s %-10s %-34s %6s %12s %6s %12s",
"Pos", "Produkt", "Bezeichnung", "Menge", "Einzelpreis", "USt", "Summe"));
int pos = 1;
for (Dokumentposition position : dokument.getPositionen()) {
schreiber.zeile(normal, 10, String.format("%-4d %-10s %-34s %6d %12s %5s%% %12s",
pos++,
position.getProduktReferenz(),
kuerze(position.getBezeichnung(), 34),
position.getMenge(),
betrag(position.getEinzelpreisNetto()),
prozent(position.getSteuersatz()),
betrag(position.getPositionssummeNetto())));
}
schreiber.leer();
schreiber.zeile(normal, 10, "Summe netto: " + betrag(dokument.getSummeNetto()) + " EUR");
schreiber.zeile(normal, 10, "Umsatzsteuer: " + betrag(dokument.getSummeSteuer()) + " EUR");
schreiber.zeile(fett, 11, "Summe brutto: " + betrag(dokument.getSummeBrutto()) + " EUR");
schreiber.schliesse();
if (zielDatei.getParent() != null) {
Files.createDirectories(zielDatei.getParent());
}
pdf.save(zielDatei.toFile());
} catch (IOException e) {
throw new UncheckedIOException("PDF-Export fehlgeschlagen: " + zielDatei, e);
}
}
private static String format(java.time.LocalDate datum) {
return datum == null ? "—" : DATUM.format(datum);
}
private static String betrag(BigDecimal wert) {
return wert.toPlainString().replace('.', ',');
}
private static String prozent(BigDecimal steuersatz) {
return steuersatz.multiply(new BigDecimal("100")).stripTrailingZeros().toPlainString();
}
private static String kuerze(String text, int maxLaenge) {
if (text == null) {
return "";
}
return text.length() <= maxLaenge ? text : text.substring(0, maxLaenge - 1) + "…";
}
/** Zeilenweiser Schreiber mit automatischem Seitenumbruch. */
private final class Schreiber {
private final PDDocument pdf;
private PDPageContentStream inhalt;
private float y;
Schreiber(PDDocument pdf) throws IOException {
this.pdf = pdf;
neueSeite();
}
private void neueSeite() throws IOException {
if (inhalt != null) {
inhalt.close();
}
PDPage seite = new PDPage(PDRectangle.A4);
pdf.addPage(seite);
inhalt = new PDPageContentStream(pdf, seite);
y = PDRectangle.A4.getHeight() - RAND;
}
void zeile(PDFont font, float groesse, String text) throws IOException {
if (y < RAND + ZEILENHOEHE) {
neueSeite();
}
inhalt.beginText();
inhalt.setFont(font, groesse);
inhalt.newLineAtOffset(RAND, y);
inhalt.showText(text);
inhalt.endText();
y -= ZEILENHOEHE;
}
void leer() {
y -= ZEILENHOEHE / 2;
}
void schliesse() throws IOException {
inhalt.close();
}
}
}

View File

@ -0,0 +1,12 @@
package de.team1.faktura.dokumente;
import java.nio.file.Path;
/**
* PDF-Export eines Belegs in das lokale Dateisystem
* (IF-01; A-F-04, F-07, F-10, F-15).
*/
public interface PdfExporter {
void exportiere(Dokument dokument, Path zielDatei);
}

View File

@ -0,0 +1,9 @@
package de.team1.faktura.dokumente;
/**
* Eingabedaten einer Belegposition: Produktreferenz und Menge.
* Aus dieser Angabe erzeugt der {@link DokumentService} die
* {@link Dokumentposition} mit Preis-/Steuersatz-Snapshot (GR-03).
*/
public record Positionsangabe(String produktnummer, int menge) {
}

View File

@ -0,0 +1,59 @@
package de.team1.faktura.dokumente;
import java.time.LocalDate;
/**
* Rechnung (BA-12 bis BA-14): führt die Pflichtangaben gemäß § 14 UStG
* (F-13), das Zahlungsziel (GR-06) und die Stornierung (F-19 bis F-21).
*/
public class Rechnung extends Dokument {
private LocalDate leistungsdatum;
private LocalDate zahlungsziel;
private LocalDate storniertAm;
@Override
public Belegtyp belegtyp() {
return Belegtyp.RECHNUNG;
}
/**
* Storniert eine offene Rechnung (F-19, F-20): Status wird
* {@code STORNIERT}, der Vorgang wird mit Datum protokolliert.
*/
public void storniere(LocalDate datum) {
if (getStatus() != DokumentStatus.OFFEN) {
throw new IllegalStateException(
"Nur Rechnungen im Status OFFEN können storniert werden (F-19), "
+ "aktueller Status: " + getStatus());
}
setzeStatus(DokumentStatus.STORNIERT);
this.storniertAm = datum;
}
public void storniere() {
storniere(LocalDate.now());
}
public LocalDate getLeistungsdatum() {
return leistungsdatum;
}
public void setLeistungsdatum(LocalDate leistungsdatum) {
pruefeAenderbar();
this.leistungsdatum = leistungsdatum;
}
public LocalDate getZahlungsziel() {
return zahlungsziel;
}
public void setZahlungsziel(LocalDate zahlungsziel) {
pruefeAenderbar();
this.zahlungsziel = zahlungsziel;
}
public LocalDate getStorniertAm() {
return storniertAm;
}
}

View File

@ -0,0 +1,241 @@
package de.team1.faktura.dokumente;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Standardimplementierung des {@link DokumentService} (Gruppe A, Kapitel 7):
* orchestriert {@link BelegnummernGenerator}, {@link KundenService},
* {@link ProduktService}, {@link DokumentRepository} und {@link PdfExporter}.
*/
public class StandardDokumentService implements DokumentService {
/** Standard-Zahlungsziel in Kalendertagen ab Rechnungsdatum (GR-06, F-14). */
public static final int STANDARD_ZAHLUNGSZIEL_TAGE = 14;
/** Standard-Gültigkeit eines Angebots in Kalendertagen ab Erstelldatum (F-02). */
public static final int STANDARD_GUELTIGKEIT_TAGE = 30;
private final DokumentRepository repository;
private final BelegnummernGenerator nummernGenerator;
private final KundenService kundenService;
private final ProduktService produktService;
private final PdfExporter pdfExporter;
public StandardDokumentService(DokumentRepository repository,
BelegnummernGenerator nummernGenerator,
KundenService kundenService,
ProduktService produktService,
PdfExporter pdfExporter) {
this.repository = repository;
this.nummernGenerator = nummernGenerator;
this.kundenService = kundenService;
this.produktService = produktService;
this.pdfExporter = pdfExporter;
}
@Override
public Angebot erstelleAngebot(String kundenNr, List<Positionsangabe> positionen, LocalDate gueltigBis) {
Kunde kunde = pruefeKunde(kundenNr);
List<Dokumentposition> dokumentpositionen = bauePositionen(positionen);
LocalDate datum = LocalDate.now();
Angebot angebot = new Angebot();
angebot.setBelegnummer(nummernGenerator.naechsteNummer(Belegtyp.ANGEBOT, datum.getYear()));
angebot.setDatum(datum);
angebot.setzeKunde(kunde.getKundennummer(), kunde.getName(), kunde.anschrift());
angebot.setGueltigBis(gueltigBis != null ? gueltigBis : datum.plusDays(STANDARD_GUELTIGKEIT_TAGE));
angebot.setzePositionen(dokumentpositionen);
repository.speichere(angebot);
return angebot;
}
@Override
public Auftragsbestaetigung erstelleAuftragsbestaetigung(String kundenNr, List<Positionsangabe> positionen) {
Kunde kunde = pruefeKunde(kundenNr);
List<Dokumentposition> dokumentpositionen = bauePositionen(positionen);
LocalDate datum = LocalDate.now();
Auftragsbestaetigung ab = new Auftragsbestaetigung();
ab.setBelegnummer(nummernGenerator.naechsteNummer(Belegtyp.AUFTRAGSBESTAETIGUNG, datum.getYear()));
ab.setDatum(datum);
ab.setzeKunde(kunde.getKundennummer(), kunde.getName(), kunde.anschrift());
ab.setzePositionen(dokumentpositionen);
repository.speichere(ab);
return ab;
}
@Override
public Lieferschein erstelleLieferschein(String kundenNr, List<Positionsangabe> positionen, LocalDate lieferdatum) {
Kunde kunde = pruefeKunde(kundenNr);
List<Dokumentposition> dokumentpositionen = bauePositionen(positionen);
LocalDate datum = LocalDate.now();
Lieferschein lieferschein = new Lieferschein();
lieferschein.setBelegnummer(nummernGenerator.naechsteNummer(Belegtyp.LIEFERSCHEIN, datum.getYear()));
lieferschein.setDatum(datum);
lieferschein.setzeKunde(kunde.getKundennummer(), kunde.getName(), kunde.anschrift());
lieferschein.setLieferdatum(lieferdatum != null ? lieferdatum : datum);
lieferschein.setzePositionen(dokumentpositionen);
repository.speichere(lieferschein);
return lieferschein;
}
@Override
public Rechnung erstelleRechnung(String kundenNr, List<Positionsangabe> positionen,
LocalDate rechnungsdatum, LocalDate zahlungsziel) {
Kunde kunde = pruefeKunde(kundenNr);
List<Dokumentposition> dokumentpositionen = bauePositionen(positionen);
if (rechnungsdatum == null) {
throw new ValidierungsException("Rechnungsdatum",
"Das Pflichtfeld 'Rechnungsdatum' fehlt (F-18).");
}
Rechnung rechnung = new Rechnung();
rechnung.setBelegnummer(nummernGenerator.naechsteNummer(Belegtyp.RECHNUNG, rechnungsdatum.getYear()));
rechnung.setDatum(rechnungsdatum);
rechnung.setLeistungsdatum(rechnungsdatum);
rechnung.setzeKunde(kunde.getKundennummer(), kunde.getName(), kunde.anschrift());
rechnung.setZahlungsziel(zahlungsziel != null
? zahlungsziel
: rechnungsdatum.plusDays(STANDARD_ZAHLUNGSZIEL_TAGE));
rechnung.setzePositionen(dokumentpositionen);
rechnung.setzeStatus(DokumentStatus.OFFEN);
repository.speichere(rechnung);
return rechnung;
}
@Override
public Dokument erzeugeFolgebeleg(String belegnummer) {
Dokument vorgaenger = pruefeBeleg(belegnummer);
LocalDate datum = LocalDate.now();
Dokument folgebeleg = switch (vorgaenger.belegtyp()) {
case ANGEBOT -> new Auftragsbestaetigung();
case AUFTRAGSBESTAETIGUNG -> {
Lieferschein lieferschein = new Lieferschein();
lieferschein.setLieferdatum(datum);
yield lieferschein;
}
case LIEFERSCHEIN -> {
Rechnung rechnung = new Rechnung();
rechnung.setLeistungsdatum(datum);
rechnung.setZahlungsziel(datum.plusDays(STANDARD_ZAHLUNGSZIEL_TAGE));
yield rechnung;
}
case RECHNUNG -> throw new ValidierungsException("Beleg",
"Für eine Rechnung kann kein Folgebeleg erzeugt werden.");
};
folgebeleg.setBelegnummer(nummernGenerator.naechsteNummer(folgebeleg.belegtyp(), datum.getYear()));
folgebeleg.setDatum(datum);
// Übernahme von Kunde, Positionen und Mengen aus dem Vorgänger (GR-05, F-22)
folgebeleg.setzeKunde(vorgaenger.getKundenReferenz(),
vorgaenger.getKundeName(), vorgaenger.getKundeAnschrift());
folgebeleg.setzePositionen(new ArrayList<>(vorgaenger.getPositionen()));
folgebeleg.setVorgaengerNr(vorgaenger.getBelegnummer());
if (folgebeleg instanceof Rechnung rechnung) {
rechnung.setLeistungsdatum(datum);
rechnung.setzeStatus(DokumentStatus.OFFEN);
}
repository.speichere(folgebeleg);
return folgebeleg;
}
@Override
public void versende(String belegnummer) {
Dokument dokument = pruefeBeleg(belegnummer);
dokument.versende();
repository.speichere(dokument);
}
@Override
public void storniere(String rechnungsnummer) {
Dokument dokument = pruefeBeleg(rechnungsnummer);
if (!(dokument instanceof Rechnung rechnung)) {
throw new ValidierungsException("Beleg",
"Nur Rechnungen können storniert werden (F-19).");
}
rechnung.storniere();
repository.speichere(rechnung);
}
@Override
public List<Dokument> alleDokumente() {
return repository.alle();
}
@Override
public List<Rechnung> offeneRechnungen() {
return repository.alle().stream()
.filter(d -> d instanceof Rechnung && d.getStatus() == DokumentStatus.OFFEN)
.map(d -> (Rechnung) d)
.toList();
}
@Override
public Summen berechneSummen(List<Positionsangabe> positionen) {
Rechnung probe = new Rechnung();
probe.setzePositionen(bauePositionen(positionen));
return new Summen(probe.getSummeNetto(), probe.getSummeSteuer(), probe.getSummeBrutto());
}
@Override
public void exportierePdf(String belegnummer, Path zielDatei) {
pdfExporter.exportiere(pruefeBeleg(belegnummer), zielDatei);
}
private Kunde pruefeKunde(String kundenNr) {
if (kundenNr == null || kundenNr.isBlank()) {
throw new ValidierungsException("Kunde",
"Das Pflichtfeld 'Kunde' fehlt (F-18).");
}
Kunde kunde = kundenService.findeKunde(kundenNr);
if (kunde == null) {
throw new ValidierungsException("Kunde",
"Der Kunde " + kundenNr + " existiert nicht.");
}
return kunde;
}
private Dokument pruefeBeleg(String belegnummer) {
Dokument dokument = repository.findeNachNummer(belegnummer);
if (dokument == null) {
throw new ValidierungsException("Beleg",
"Der Beleg " + belegnummer + " existiert nicht.");
}
return dokument;
}
/** Baut die Positionen mit Produkt-Snapshot; mindestens eine Position erforderlich (F-18). */
private List<Dokumentposition> bauePositionen(List<Positionsangabe> positionen) {
if (positionen == null || positionen.isEmpty()) {
throw new ValidierungsException("Position",
"Mindestens eine 'Position' ist erforderlich (F-18).");
}
List<Dokumentposition> ergebnis = new ArrayList<>();
for (Positionsangabe angabe : positionen) {
if (angabe.menge() <= 0) {
throw new ValidierungsException("Menge",
"Die 'Menge' muss größer als 0 sein.");
}
Produkt produkt = produktService.findeProdukt(angabe.produktnummer());
if (produkt == null) {
throw new ValidierungsException("Produkt",
"Das Produkt " + angabe.produktnummer() + " existiert nicht.");
}
// Snapshot von Bezeichnung, Einzelpreis und Steuersatz (GR-03, F-23)
ergebnis.add(new Dokumentposition(produkt.getProduktnummer(), produkt.getBezeichnung(),
angabe.menge(), produkt.getEinzelpreisNetto(), produkt.getSteuersatz()));
}
return ergebnis;
}
}

View File

@ -0,0 +1,10 @@
package de.team1.faktura.dokumente;
import java.math.BigDecimal;
/**
* Berechnete Netto-, Steuer- und Bruttosumme eines Belegs (F-23);
* wird u. a. für die Wizard-Zusammenfassung (D-F-12) bereitgestellt.
*/
public record Summen(BigDecimal netto, BigDecimal steuer, BigDecimal brutto) {
}

View File

@ -0,0 +1,43 @@
package de.team1.faktura.gemeinsam;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Hilfsfunktionen für den CSV-Export der Stammdaten
* (B-F-15, C-F-15: UTF-8, Semikolon-getrennt, mit Kopfzeile).
*/
public final class Csv {
public static final String TRENNZEICHEN = ";";
private Csv() {
}
/** Maskiert einen Wert für CSV; {@code null} wird als leeres Feld geschrieben. */
public static String feld(String wert) {
if (wert == null) {
return "";
}
if (wert.contains(TRENNZEICHEN) || wert.contains("\"") || wert.contains("\n")) {
return "\"" + wert.replace("\"", "\"\"") + "\"";
}
return wert;
}
/** Schreibt die Zeilen als UTF-8-Datei in das lokale Dateisystem (IF-04). */
public static void schreibe(Path zielDatei, List<String> zeilen) {
try {
if (zielDatei.getParent() != null) {
Files.createDirectories(zielDatei.getParent());
}
Files.write(zielDatei, zeilen, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException("CSV-Export fehlgeschlagen: " + zielDatei, e);
}
}
}

View File

@ -0,0 +1,26 @@
package de.team1.faktura.gemeinsam;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
/**
* Zentral konfigurierter Jackson-ObjectMapper für die lokale
* JSON-Persistenz (IF-01). Datumswerte werden als ISO-Strings
* geschrieben (offenes, dokumentiertes Format, Q-08).
*/
public final class JsonPersistenz {
private JsonPersistenz() {
}
public static ObjectMapper mapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.enable(SerializationFeature.INDENT_OUTPUT);
return mapper;
}
}

View File

@ -0,0 +1,13 @@
package de.team1.faktura.gemeinsam;
/**
* Ablehnung eines Löschvorgangs wegen referenzieller Integrität
* (GR-04 Kunden, B-F-09 Produkte). Die Meldung enthält den Hinweis
* für die Anwender:in (bei Kunden inkl. Anzahl verknüpfter Dokumente).
*/
public class LoeschAbgelehntException extends RuntimeException {
public LoeschAbgelehntException(String message) {
super(message);
}
}

View File

@ -0,0 +1,20 @@
package de.team1.faktura.gemeinsam;
/**
* Validierungsfehler, der das betroffene Eingabefeld namentlich benennt
* (Q-09: Pflichtfeldhinweise; A-F-18, B-F-04, C-F-03, D-F-16).
*/
public class ValidierungsException extends RuntimeException {
private final String feldname;
public ValidierungsException(String feldname, String message) {
super(message);
this.feldname = feldname;
}
/** Name des fehlenden oder ungültigen Eingabefelds, z. B. "Ort". */
public String getFeldname() {
return feldname;
}
}

View File

@ -0,0 +1,9 @@
package de.team1.faktura.gui;
/**
* Je Beleg verfügbare Aktionen der Dokumentliste (D-F-07, F-08, F-14):
* inhaltliche Änderungsaktionen sind bei versendeten/stornierten Belegen
* deaktiviert, der PDF-Export bleibt stets verfügbar.
*/
public record BelegAktionen(boolean stornierbar, boolean aenderbar, boolean pdfExport) {
}

View File

@ -0,0 +1,186 @@
package de.team1.faktura.gui;
import de.team1.faktura.dokumente.Belegtyp;
import de.team1.faktura.dokumente.Dokument;
import de.team1.faktura.dokumente.DokumentService;
import de.team1.faktura.dokumente.Positionsangabe;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Window;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Dialog zur direkten Erstellung von Angebot, Auftragsbestätigung oder
* Lieferschein (BA-09 bis BA-11). Rechnungen werden über die geführte
* Erstellung (Wizard, D-F-09) angelegt; Folgebelege über die Dokumentliste.
*/
public class BelegDialog extends JDialog {
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final DokumentService dokumentService;
private final JComboBox<Belegtyp> typWahl = new JComboBox<>(
new Belegtyp[]{Belegtyp.ANGEBOT, Belegtyp.AUFTRAGSBESTAETIGUNG, Belegtyp.LIEFERSCHEIN});
private final JComboBox<Kunde> kundenWahl = new JComboBox<>();
private final JComboBox<Produkt> produktWahl = new JComboBox<>();
private final JSpinner mengeWahl = new JSpinner(new SpinnerNumberModel(1, 1, 99999, 1));
private final DefaultListModel<String> positionsListenModel = new DefaultListModel<>();
private final JList<String> positionsListe = new JList<>(positionsListenModel);
private final JLabel datumBeschriftung = new JLabel("Gültig bis (leer = +30 Tage):");
private final JTextField datumFeld = new JTextField(10);
private final List<Positionsangabe> positionen = new ArrayList<>();
public BelegDialog(Window besitzer, DokumentService dokumentService,
KundenService kundenService, ProduktService produktService) {
super(besitzer, "Neuen Beleg erstellen", ModalityType.APPLICATION_MODAL);
this.dokumentService = dokumentService;
kundenWahl.setModel(new DefaultComboBoxModel<>(
kundenService.suche("").toArray(new Kunde[0])));
produktWahl.setModel(new DefaultComboBoxModel<>(
produktService.suche("").toArray(new Produkt[0])));
baueOberflaeche();
pack();
setLocationRelativeTo(besitzer);
}
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
JPanel kopf = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
kopf.add(new JLabel("Belegtyp: *"), c);
c.gridx = 1;
typWahl.addActionListener(e -> aktualisiereDatumsfeld());
kopf.add(typWahl, c);
c.gridx = 0;
c.gridy = 1;
kopf.add(new JLabel("Kunde: *"), c);
c.gridx = 1;
kopf.add(kundenWahl, c);
c.gridx = 0;
c.gridy = 2;
kopf.add(datumBeschriftung, c);
c.gridx = 1;
kopf.add(datumFeld, c);
add(kopf, BorderLayout.NORTH);
JPanel mitte = new JPanel(new BorderLayout(5, 5));
JPanel eingabe = new JPanel(new FlowLayout(FlowLayout.LEFT));
eingabe.add(new JLabel("Produkt:"));
eingabe.add(produktWahl);
eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen");
hinzufuegen.addActionListener(e -> fuegePositionHinzu());
eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen");
entfernen.addActionListener(e -> entfernePosition());
eingabe.add(entfernen);
mitte.add(eingabe, BorderLayout.NORTH);
mitte.add(new JScrollPane(positionsListe), BorderLayout.CENTER);
add(mitte, BorderLayout.CENTER);
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen");
abbrechen.addActionListener(e -> dispose());
JButton erstellen = new JButton("Erstellen");
erstellen.addActionListener(e -> erstelle());
knoepfe.add(abbrechen);
knoepfe.add(erstellen);
add(knoepfe, BorderLayout.SOUTH);
}
private void aktualisiereDatumsfeld() {
Belegtyp typ = (Belegtyp) typWahl.getSelectedItem();
datumBeschriftung.setText(switch (typ) {
case ANGEBOT -> "Gültig bis (leer = +30 Tage):";
case LIEFERSCHEIN -> "Lieferdatum (leer = heute):";
default -> "Datum (entfällt):";
});
datumFeld.setEnabled(typ == Belegtyp.ANGEBOT || typ == Belegtyp.LIEFERSCHEIN);
}
private void fuegePositionHinzu() {
Produkt produkt = (Produkt) produktWahl.getSelectedItem();
if (produkt == null) {
return;
}
int menge = (Integer) mengeWahl.getValue();
positionen.add(new Positionsangabe(produkt.getProduktnummer(), menge));
positionsListenModel.addElement(menge + " x " + produkt.getBezeichnung()
+ " (" + produkt.getProduktnummer() + ")");
}
private void entfernePosition() {
int index = positionsListe.getSelectedIndex();
if (index >= 0) {
positionen.remove(index);
positionsListenModel.remove(index);
}
}
private void erstelle() {
Kunde kunde = (Kunde) kundenWahl.getSelectedItem();
String kundenNr = kunde == null ? null : kunde.getKundennummer();
LocalDate datum;
try {
String text = datumFeld.getText().trim();
datum = text.isEmpty() ? null : LocalDate.parse(text, DATUM);
} catch (DateTimeParseException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler("Datum",
"Das Datum ist ungültig. Format: TT.MM.JJJJ"), null);
return;
}
try {
Belegtyp typ = (Belegtyp) typWahl.getSelectedItem();
Dokument beleg = switch (typ) {
case ANGEBOT -> dokumentService.erstelleAngebot(kundenNr, positionen, datum);
case AUFTRAGSBESTAETIGUNG -> dokumentService.erstelleAuftragsbestaetigung(kundenNr, positionen);
case LIEFERSCHEIN -> dokumentService.erstelleLieferschein(kundenNr, positionen, datum);
default -> throw new IllegalStateException("Unerwarteter Belegtyp: " + typ);
};
MeldungsAnzeige.zeige(this, Meldung.erfolg(beleg.belegtyp().anzeigename() + " "
+ beleg.getBelegnummer() + " wurde erstellt."), null);
dispose();
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), null);
}
}
}

View File

@ -0,0 +1,61 @@
package de.team1.faktura.gui;
import de.team1.faktura.dokumente.Dokument;
import de.team1.faktura.dokumente.DokumentService;
import de.team1.faktura.dokumente.DokumentStatus;
import de.team1.faktura.dokumente.Rechnung;
import de.team1.faktura.gemeinsam.ValidierungsException;
import java.util.List;
/**
* Dialogführung der Dokumentliste (D-F-06 bis F-08, F-14, F-15):
* Statusfilter, verfügbare Aktionen je Beleg und Stornierung nach
* Bestätigung. GUI-frei und damit ohne Oberfläche testbar.
*/
public class DokumentListenController {
private final DokumentService dokumentService;
public DokumentListenController(DokumentService dokumentService) {
this.dokumentService = dokumentService;
}
/** Dokumentliste, optional nach Status gefiltert (F-06); {@code null} = alle. */
public List<Dokument> gefiltert(DokumentStatus statusFilter) {
return dokumentService.alleDokumente().stream()
.filter(d -> statusFilter == null || d.getStatus() == statusFilter)
.toList();
}
/**
* Verfügbare Aktionen je Beleg: <i>Stornieren</i> nur für Rechnungen im
* Status {@code OFFEN} (F-14); inhaltliche Änderungen nur solange der
* Beleg nicht versendet/storniert ist (F-08, GR-02); PDF-Export immer.
*/
public BelegAktionen aktionenFuer(Dokument dokument) {
boolean stornierbar = dokument instanceof Rechnung
&& dokument.getStatus() == DokumentStatus.OFFEN;
boolean aenderbar = dokument.getStatus() == DokumentStatus.ENTWURF
|| dokument.getStatus() == DokumentStatus.OFFEN;
return new BelegAktionen(stornierbar, aenderbar, true);
}
/**
* Storniert erst nach Bestätigung der Anwender:in (F-15); ohne
* Bestätigung erfolgt kein Aufruf an die Fachkomponente.
*/
public Meldung storniere(String rechnungsnummer, boolean bestaetigt) {
if (!bestaetigt) {
return null;
}
try {
dokumentService.storniere(rechnungsnummer);
return Meldung.erfolg("Die Rechnung " + rechnungsnummer + " wurde storniert.");
} catch (ValidierungsException e) {
return Meldung.fehler(e.getFeldname(), e.getMessage());
} catch (IllegalStateException e) {
return Meldung.fehler(null, e.getMessage());
}
}
}

View File

@ -0,0 +1,312 @@
package de.team1.faktura.gui;
import de.team1.faktura.dokumente.Dokument;
import de.team1.faktura.dokumente.DokumentService;
import de.team1.faktura.dokumente.DokumentStatus;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.ProduktService;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.Desktop;
import java.awt.FlowLayout;
import java.awt.Window;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* Modulansicht Dokumente (D-F-06 bis F-08, F-14, F-15): Dokumentliste mit
* Statusfilter, Belegaktionen (PDF-Export, optional Druck und E-Mail),
* geführte Rechnungserstellung (Wizard) und Stornierung mit
* Bestätigungsdialog.
*/
public class DokumentListenPanel extends JPanel implements ModulPanel {
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final DokumentService dokumentService;
private final KundenService kundenService;
private final ProduktService produktService;
private final DokumentListenController controller;
private final JComboBox<String> statusFilter = new JComboBox<>(
new String[]{"Alle", "ENTWURF", "OFFEN", "VERSENDET", "STORNIERT"});
private final DokumentTabellenModel tabellenModel = new DokumentTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
private final JButton folgebelegKnopf = new JButton("Folgebeleg erzeugen");
private final JButton versendenKnopf = new JButton("Versenden");
private final JButton stornierenKnopf = new JButton("Stornieren");
private final JButton pdfKnopf = new JButton("PDF exportieren");
private final JButton druckenKnopf = new JButton("Drucken");
private final JButton mailKnopf = new JButton("Per E-Mail senden");
public DokumentListenPanel(DokumentService dokumentService,
KundenService kundenService,
ProduktService produktService) {
this.dokumentService = dokumentService;
this.kundenService = kundenService;
this.produktService = produktService;
this.controller = new DokumentListenController(dokumentService);
baueOberflaeche();
aktualisiere();
}
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
JPanel kopf = new JPanel(new FlowLayout(FlowLayout.LEFT));
kopf.add(new JLabel("Statusfilter:"));
kopf.add(statusFilter);
statusFilter.addActionListener(e -> aktualisiere());
JButton neueRechnung = new JButton("Neue Rechnung (Assistent)…");
neueRechnung.addActionListener(e -> oeffneWizard());
JButton neuerBeleg = new JButton("Neuer Beleg…");
neuerBeleg.addActionListener(e -> oeffneBelegDialog());
kopf.add(neueRechnung);
kopf.add(neuerBeleg);
add(kopf, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
add(new JScrollPane(tabelle), BorderLayout.CENTER);
JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
folgebelegKnopf.addActionListener(e -> erzeugeFolgebeleg());
versendenKnopf.addActionListener(e -> versende());
stornierenKnopf.addActionListener(e -> storniere());
pdfKnopf.addActionListener(e -> exportierePdf());
druckenKnopf.addActionListener(e -> drucke());
mailKnopf.addActionListener(e -> sendePerMail());
aktionen.add(folgebelegKnopf);
aktionen.add(versendenKnopf);
aktionen.add(stornierenKnopf);
aktionen.add(pdfKnopf);
aktionen.add(druckenKnopf);
aktionen.add(mailKnopf);
add(aktionen, BorderLayout.SOUTH);
aktualisiereAktionen();
}
private Dokument auswahl() {
int zeile = tabelle.getSelectedRow();
return zeile < 0 ? null : tabellenModel.dokumente.get(zeile);
}
/** Aktiviert/deaktiviert die Belegaktionen gemäß Status (F-08, F-14). */
private void aktualisiereAktionen() {
Dokument dokument = auswahl();
if (dokument == null) {
for (JButton knopf : List.of(folgebelegKnopf, versendenKnopf, stornierenKnopf,
pdfKnopf, druckenKnopf, mailKnopf)) {
knopf.setEnabled(false);
}
return;
}
BelegAktionen verfuegbar = controller.aktionenFuer(dokument);
folgebelegKnopf.setEnabled(dokument.belegtyp() != de.team1.faktura.dokumente.Belegtyp.RECHNUNG);
versendenKnopf.setEnabled(verfuegbar.aenderbar());
stornierenKnopf.setEnabled(verfuegbar.stornierbar());
pdfKnopf.setEnabled(verfuegbar.pdfExport());
druckenKnopf.setEnabled(verfuegbar.pdfExport());
mailKnopf.setEnabled(verfuegbar.pdfExport());
}
private void oeffneWizard() {
RechnungsWizardController wizardController =
new RechnungsWizardController(dokumentService, kundenService, produktService);
Window fenster = SwingUtilities.getWindowAncestor(this);
RechnungsWizardDialog dialog = new RechnungsWizardDialog(fenster, wizardController,
kundenService, produktService);
dialog.setVisible(true);
aktualisiere();
}
private void oeffneBelegDialog() {
Window fenster = SwingUtilities.getWindowAncestor(this);
BelegDialog dialog = new BelegDialog(fenster, dokumentService, kundenService, produktService);
dialog.setVisible(true);
aktualisiere();
}
private void erzeugeFolgebeleg() {
Dokument dokument = auswahl();
if (dokument == null) {
return;
}
try {
Dokument folgebeleg = dokumentService.erzeugeFolgebeleg(dokument.getBelegnummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg(folgebeleg.belegtyp().anzeigename() + " "
+ folgebeleg.getBelegnummer() + " wurde aus " + dokument.getBelegnummer()
+ " erzeugt."), null);
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), null);
}
}
private void versende() {
Dokument dokument = auswahl();
if (dokument == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
"Beleg " + dokument.getBelegnummer() + " als versendet markieren?\n"
+ "Danach sind keine inhaltlichen Änderungen mehr möglich (GR-02).",
"Versenden", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
try {
dokumentService.versende(dokument.getBelegnummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Beleg " + dokument.getBelegnummer()
+ " ist jetzt im Status VERSENDET."), null);
} catch (IllegalStateException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), null);
}
}
/** Stornierung mit Bestätigungsdialog: Rechnungsnummer und Bruttosumme (F-15). */
private void storniere() {
Dokument dokument = auswahl();
if (dokument == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
"Rechnung " + dokument.getBelegnummer() + " über "
+ dokument.getSummeBrutto().toPlainString() + " EUR (brutto) wirklich stornieren?",
"Rechnung stornieren", JOptionPane.YES_NO_OPTION);
Meldung meldung = controller.storniere(dokument.getBelegnummer(),
antwort == JOptionPane.YES_OPTION);
aktualisiere();
MeldungsAnzeige.zeige(this, meldung, null);
}
private void exportierePdf() {
Dokument dokument = auswahl();
if (dokument == null) {
return;
}
JFileChooser auswahlDialog = new JFileChooser();
auswahlDialog.setSelectedFile(new java.io.File(dokument.getBelegnummer() + ".pdf"));
if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
Path ziel = auswahlDialog.getSelectedFile().toPath();
dokumentService.exportierePdf(dokument.getBelegnummer(), ziel);
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das PDF wurde exportiert nach " + ziel), null);
}
}
/** Optionaler Druck über das Betriebssystem (IF-02). */
private void drucke() {
Dokument dokument = auswahl();
if (dokument == null) {
return;
}
try {
Path temp = Files.createTempFile(dokument.getBelegnummer() + "-", ".pdf");
dokumentService.exportierePdf(dokument.getBelegnummer(), temp);
Desktop.getDesktop().print(temp.toFile());
} catch (Exception e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null,
"Drucken nicht möglich: " + e.getMessage()), null);
}
}
/** Optionaler Versand über den Standard-E-Mail-Client (IF-03). */
private void sendePerMail() {
Dokument dokument = auswahl();
if (dokument == null) {
return;
}
try {
Path temp = Files.createTempFile(dokument.getBelegnummer() + "-", ".pdf");
dokumentService.exportierePdf(dokument.getBelegnummer(), temp);
String betreff = URLEncoder.encode(dokument.belegtyp().anzeigename() + " "
+ dokument.getBelegnummer(), StandardCharsets.UTF_8).replace("+", "%20");
String text = URLEncoder.encode("Bitte das exportierte PDF anhängen:\n" + temp,
StandardCharsets.UTF_8).replace("+", "%20");
Desktop.getDesktop().mail(new URI("mailto:?subject=" + betreff + "&body=" + text));
} catch (Exception e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null,
"E-Mail-Client konnte nicht geöffnet werden: " + e.getMessage()), null);
}
}
@Override
public boolean hatUngespeicherteAenderungen() {
return false;
}
@Override
public void aktualisiere() {
DokumentStatus filter = switch ((String) statusFilter.getSelectedItem()) {
case "ENTWURF" -> DokumentStatus.ENTWURF;
case "OFFEN" -> DokumentStatus.OFFEN;
case "VERSENDET" -> DokumentStatus.VERSENDET;
case "STORNIERT" -> DokumentStatus.STORNIERT;
default -> null;
};
tabellenModel.setze(controller.gefiltert(filter));
aktualisiereAktionen();
}
private static final class DokumentTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN =
{"Belegnummer", "Typ", "Datum", "Kunde", "Bruttosumme", "Status"};
private List<Dokument> dokumente = new ArrayList<>();
void setze(List<Dokument> neueDokumente) {
this.dokumente = new ArrayList<>(neueDokumente);
fireTableDataChanged();
}
@Override
public int getRowCount() {
return dokumente.size();
}
@Override
public int getColumnCount() {
return SPALTEN.length;
}
@Override
public String getColumnName(int spalte) {
return SPALTEN[spalte];
}
@Override
public Object getValueAt(int zeile, int spalte) {
Dokument dokument = dokumente.get(zeile);
return switch (spalte) {
case 0 -> dokument.getBelegnummer();
case 1 -> dokument.belegtyp().anzeigename();
case 2 -> dokument.getDatum() == null ? "" : DATUM.format(dokument.getDatum());
case 3 -> dokument.getKundeName() + " (" + dokument.getKundenReferenz() + ")";
case 4 -> dokument.getSummeBrutto().toPlainString() + " EUR";
default -> dokument.getStatus().name();
};
}
}
}

View File

@ -0,0 +1,76 @@
package de.team1.faktura.gui;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JToolBar;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Hauptfenster mit Navigation zu den drei Modulen Kundenverwaltung,
* Produktverwaltung und Dokumente (D-F-01). Beim Modulwechsel wird bei
* ungespeicherten Formulareingaben nachgefragt (D-F-02).
*/
public class HauptFenster extends JFrame {
private final CardLayout karten = new CardLayout();
private final JPanel kartenPanel = new JPanel(karten);
private final Map<String, ModulPanel> module = new LinkedHashMap<>();
private String aktuellesModul;
public HauptFenster(KundenPanel kundenPanel, ProduktPanel produktPanel,
DokumentListenPanel dokumentePanel) {
super("Fakturierung — Team 1");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setLayout(new BorderLayout());
module.put("Kunden", kundenPanel);
module.put("Produkte", produktPanel);
module.put("Dokumente", dokumentePanel);
JToolBar navigation = new JToolBar();
navigation.setFloatable(false);
for (Map.Entry<String, ModulPanel> eintrag : module.entrySet()) {
JButton knopf = new JButton(eintrag.getKey());
knopf.addActionListener(e -> wechsleZu(eintrag.getKey()));
navigation.add(knopf);
}
add(navigation, BorderLayout.NORTH);
kartenPanel.add(kundenPanel, "Kunden");
kartenPanel.add(produktPanel, "Produkte");
kartenPanel.add(dokumentePanel, "Dokumente");
add(kartenPanel, BorderLayout.CENTER);
aktuellesModul = "Kunden";
karten.show(kartenPanel, aktuellesModul);
setSize(1100, 650);
setLocationRelativeTo(null);
}
/** Modulwechsel mit Nachfrage bei ungespeicherten Eingaben (D-F-02). */
private void wechsleZu(String modulName) {
if (modulName.equals(aktuellesModul)) {
return;
}
ModulPanel aktuell = module.get(aktuellesModul);
if (aktuell.hatUngespeicherteAenderungen()) {
int antwort = JOptionPane.showConfirmDialog(this,
"Das Formular enthält ungespeicherte Eingaben. Modul trotzdem wechseln?",
"Ungespeicherte Eingaben", JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
}
aktuellesModul = modulName;
module.get(modulName).aktualisiere();
karten.show(kartenPanel, modulName);
}
}

View File

@ -0,0 +1,330 @@
package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenCsvExport;
import de.team1.faktura.kunden.KundenVerwaltungsService;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Modulansicht Kundenverwaltung (D-F-03 bis F-05): sortierte Liste mit
* Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export.
*/
public class KundenPanel extends JPanel implements ModulPanel {
private final KundenVerwaltungsService service;
private final KundenCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20);
private final KundenTabellenModel tabellenModel = new KundenTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
private final JTextField nameFeld = new JTextField(20);
private final JTextField strasseFeld = new JTextField(20);
private final JTextField plzFeld = new JTextField(8);
private final JTextField ortFeld = new JTextField(20);
private final JTextField eMailFeld = new JTextField(20);
private final JTextField telefonFeld = new JTextField(20);
private final JTextField ustIdNrFeld = new JTextField(20);
private final JLabel nummerAnzeige = new JLabel("— neuer Kunde —");
private final Map<String, JComponent> felder = new LinkedHashMap<>();
private String gewaehlteNummer;
private boolean ungespeichert;
public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport) {
this.service = service;
this.csvExport = csvExport;
felder.put("Name", nameFeld);
felder.put("Straße", strasseFeld);
felder.put("PLZ", plzFeld);
felder.put("Ort", ortFeld);
felder.put("E-Mail", eMailFeld);
baueOberflaeche();
aktualisiere();
}
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT));
suchleiste.add(new JLabel("Suche (Name oder Kundennummer):"));
suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
tabelle.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
ladeAuswahl();
}
});
JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
new JScrollPane(tabelle), baueFormular());
teiler.setResizeWeight(0.55);
add(teiler, BorderLayout.CENTER);
}
private JPanel baueFormular() {
JPanel formular = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int zeile = 0;
zeile = formularZeile(formular, c, zeile, "Kundennummer:", nummerAnzeige);
zeile = formularZeile(formular, c, zeile, "Name: *", nameFeld);
zeile = formularZeile(formular, c, zeile, "Straße: *", strasseFeld);
zeile = formularZeile(formular, c, zeile, "PLZ: *", plzFeld);
zeile = formularZeile(formular, c, zeile, "Ort: *", ortFeld);
zeile = formularZeile(formular, c, zeile, "E-Mail:", eMailFeld);
zeile = formularZeile(formular, c, zeile, "Telefon:", telefonFeld);
zeile = formularZeile(formular, c, zeile, "USt-IdNr.:", ustIdNrFeld);
DocumentListener aenderungsListener = neuerAenderungsListener();
for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
eMailFeld, telefonFeld, ustIdNrFeld)) {
((JTextField) feld).getDocument().addDocumentListener(aenderungsListener);
}
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton neu = new JButton("Neu");
neu.addActionListener(e -> leereFormular());
JButton speichern = new JButton("Speichern");
speichern.addActionListener(e -> speichere());
JButton loeschen = new JButton("Löschen");
loeschen.addActionListener(e -> loesche());
JButton export = new JButton("CSV-Export");
export.addActionListener(e -> exportiere());
knoepfe.add(neu);
knoepfe.add(speichern);
knoepfe.add(loeschen);
knoepfe.add(export);
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 2;
formular.add(knoepfe, c);
return formular;
}
private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
String beschriftung, JComponent feld) {
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 1;
c.weightx = 0;
formular.add(new JLabel(beschriftung), c);
c.gridx = 1;
c.weightx = 1;
formular.add(feld, c);
return zeile + 1;
}
private void speichere() {
try {
Kunde kunde = gewaehlteNummer == null
? new Kunde()
: service.findeKunde(gewaehlteNummer);
kunde.setName(nameFeld.getText().trim());
kunde.setStrasse(strasseFeld.getText().trim());
kunde.setPlz(plzFeld.getText().trim());
kunde.setOrt(ortFeld.getText().trim());
kunde.setEMail(leerZuNull(eMailFeld.getText()));
kunde.setTelefon(leerZuNull(telefonFeld.getText()));
kunde.setUstIdNr(leerZuNull(ustIdNrFeld.getText()));
Kunde gespeichert = gewaehlteNummer == null
? service.legeAn(kunde)
: service.aendere(kunde);
ungespeichert = false;
gewaehlteNummer = gespeichert.getKundennummer();
nummerAnzeige.setText(gespeichert.getKundennummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gespeichert. Kundennummer: "
+ gespeichert.getKundennummer()), felder);
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), felder);
}
}
private void loesche() {
if (gewaehlteNummer == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
"Kunde " + gewaehlteNummer + " wirklich dauerhaft löschen?",
"Kunde löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
try {
service.loescheKunde(gewaehlteNummer);
leereFormular();
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), felder);
} catch (LoeschAbgelehntException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), felder);
}
}
private void exportiere() {
JFileChooser auswahl = new JFileChooser();
auswahl.setSelectedFile(new java.io.File("kunden.csv"));
if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
csvExport.exportiereCsv(auswahl.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Kundenstammdaten wurden exportiert nach "
+ auswahl.getSelectedFile()), felder);
}
}
private void ladeAuswahl() {
int zeile = tabelle.getSelectedRow();
if (zeile < 0) {
return;
}
Kunde kunde = tabellenModel.kunden.get(zeile);
gewaehlteNummer = kunde.getKundennummer();
nummerAnzeige.setText(kunde.getKundennummer());
nameFeld.setText(kunde.getName());
strasseFeld.setText(kunde.getStrasse());
plzFeld.setText(kunde.getPlz());
ortFeld.setText(kunde.getOrt());
eMailFeld.setText(kunde.getEMail() == null ? "" : kunde.getEMail());
telefonFeld.setText(kunde.getTelefon() == null ? "" : kunde.getTelefon());
ustIdNrFeld.setText(kunde.getUstIdNr() == null ? "" : kunde.getUstIdNr());
ungespeichert = false;
}
private void leereFormular() {
gewaehlteNummer = null;
nummerAnzeige.setText("— neuer Kunde —");
for (JComponent feld : List.of(nameFeld, strasseFeld, plzFeld, ortFeld,
eMailFeld, telefonFeld, ustIdNrFeld)) {
((JTextField) feld).setText("");
}
tabelle.clearSelection();
ungespeichert = false;
}
private static String leerZuNull(String text) {
String wert = text.trim();
return wert.isEmpty() ? null : wert;
}
@Override
public boolean hatUngespeicherteAenderungen() {
return ungespeichert;
}
@Override
public void aktualisiere() {
String begriff = suchfeld.getText().trim();
tabellenModel.setze(begriff.isEmpty()
? service.alleSortiertNachName()
: service.suche(begriff));
}
private DocumentListener neuerSuchListener() {
return new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
aktualisiere();
}
@Override
public void removeUpdate(DocumentEvent e) {
aktualisiere();
}
@Override
public void changedUpdate(DocumentEvent e) {
aktualisiere();
}
};
}
private DocumentListener neuerAenderungsListener() {
return new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
ungespeichert = true;
}
};
}
private static final class KundenTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Kundennummer", "Name", "Straße", "PLZ", "Ort"};
private List<Kunde> kunden = new ArrayList<>();
void setze(List<Kunde> neueKunden) {
this.kunden = new ArrayList<>(neueKunden);
fireTableDataChanged();
}
@Override
public int getRowCount() {
return kunden.size();
}
@Override
public int getColumnCount() {
return SPALTEN.length;
}
@Override
public String getColumnName(int spalte) {
return SPALTEN[spalte];
}
@Override
public Object getValueAt(int zeile, int spalte) {
Kunde kunde = kunden.get(zeile);
return switch (spalte) {
case 0 -> kunde.getKundennummer();
case 1 -> kunde.getName();
case 2 -> kunde.getStrasse();
case 3 -> kunde.getPlz();
default -> kunde.getOrt();
};
}
}
}

View File

@ -0,0 +1,19 @@
package de.team1.faktura.gui;
/**
* Einheitliche Fehler- und Erfolgsmeldung der Oberfläche (D-F-16, F-17).
*
* @param typ Erfolg oder Fehler
* @param feldname betroffenes Eingabefeld bei Validierungsfehlern, sonst {@code null}
* @param text anzuzeigender Meldungstext
*/
public record Meldung(MeldungsTyp typ, String feldname, String text) {
public static Meldung erfolg(String text) {
return new Meldung(MeldungsTyp.ERFOLG, null, text);
}
public static Meldung fehler(String feldname, String text) {
return new Meldung(MeldungsTyp.FEHLER, feldname, text);
}
}

View File

@ -0,0 +1,51 @@
package de.team1.faktura.gui;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.UIManager;
import javax.swing.border.Border;
import java.awt.Color;
import java.awt.Component;
import java.util.Map;
/**
* Einheitliche Darstellung von Fehler- und Erfolgsmeldungen (D-F-16, F-17):
* Das betroffene Eingabefeld wird optisch markiert UND die Meldung benennt
* das Feld namentlich (Q-09).
*/
public final class MeldungsAnzeige {
private static final Border FEHLER_RAND = BorderFactory.createLineBorder(Color.RED, 2);
private MeldungsAnzeige() {
}
/**
* Zeigt die Meldung als Dialog an und markiert bei Validierungsfehlern
* das betroffene Feld rot; alle übrigen Felder werden zurückgesetzt.
*/
public static void zeige(Component parent, Meldung meldung, Map<String, JComponent> felder) {
if (felder != null) {
felder.forEach((name, feld) -> feld.setBorder(UIManager.getBorder("TextField.border")));
if (meldung != null && meldung.feldname() != null) {
JComponent feld = felder.get(meldung.feldname());
if (feld != null) {
feld.setBorder(FEHLER_RAND);
feld.requestFocusInWindow();
}
}
}
if (meldung == null) {
return;
}
if (meldung.typ() == MeldungsTyp.FEHLER) {
JOptionPane.showMessageDialog(parent, meldung.text(),
meldung.feldname() != null ? "Eingabe unvollständig: " + meldung.feldname() : "Fehler",
JOptionPane.ERROR_MESSAGE);
} else {
JOptionPane.showMessageDialog(parent, meldung.text(), "Erfolg",
JOptionPane.INFORMATION_MESSAGE);
}
}
}

View File

@ -0,0 +1,9 @@
package de.team1.faktura.gui;
/**
* Art einer Benutzer-Meldung (Gruppe D, Kapitel 6.1).
*/
public enum MeldungsTyp {
ERFOLG,
FEHLER
}

View File

@ -0,0 +1,14 @@
package de.team1.faktura.gui;
/**
* Gemeinsame Schnittstelle der Modulansichten für das Hauptfenster
* (D-F-01, F-02): Navigation und Schutz ungespeicherter Eingaben.
*/
public interface ModulPanel {
/** {@code true}, wenn das Formular ungespeicherte Eingaben enthält (F-02). */
boolean hatUngespeicherteAenderungen();
/** Lädt die Daten der Ansicht neu (z. B. nach Modulwechsel). */
void aktualisiere();
}

View File

@ -0,0 +1,8 @@
package de.team1.faktura.gui;
/**
* Im Wizard erfasste Position: gewähltes Produkt und Stückzahl
* (Gruppe D, Kapitel 6.1).
*/
public record PositionsEingabe(String produktnummer, int menge) {
}

View File

@ -0,0 +1,351 @@
package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktCsvExport;
import de.team1.faktura.produkte.ProduktVerwaltungsService;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Modulansicht Produktverwaltung (D-F-03 bis F-05): sortierte Liste mit
* Suchfeld, Formular mit gekennzeichneten Pflichtfeldern, CSV-Export.
*/
public class ProduktPanel extends JPanel implements ModulPanel {
private static final String[] STEUERSAETZE = {"19 %", "7 %", "0 %"};
private final ProduktVerwaltungsService service;
private final ProduktCsvExport csvExport;
private final JTextField suchfeld = new JTextField(20);
private final ProduktTabellenModel tabellenModel = new ProduktTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
private final JTextField bezeichnungFeld = new JTextField(20);
private final JTextField beschreibungFeld = new JTextField(20);
private final JTextField preisFeld = new JTextField(10);
private final JComboBox<String> steuersatzWahl = new JComboBox<>(STEUERSAETZE);
private final JTextField einheitFeld = new JTextField(10);
private final JLabel nummerAnzeige = new JLabel("— neues Produkt —");
private final Map<String, JComponent> felder = new LinkedHashMap<>();
private String gewaehlteNummer;
private boolean ungespeichert;
public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport) {
this.service = service;
this.csvExport = csvExport;
felder.put("Bezeichnung", bezeichnungFeld);
felder.put("Einzelpreis", preisFeld);
felder.put("Steuersatz", steuersatzWahl);
baueOberflaeche();
aktualisiere();
}
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
JPanel suchleiste = new JPanel(new FlowLayout(FlowLayout.LEFT));
suchleiste.add(new JLabel("Suche (Bezeichnung oder Produktnummer):"));
suchleiste.add(suchfeld);
suchfeld.getDocument().addDocumentListener(neuerSuchListener());
add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
tabelle.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
ladeAuswahl();
}
});
JSplitPane teiler = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
new JScrollPane(tabelle), baueFormular());
teiler.setResizeWeight(0.55);
add(teiler, BorderLayout.CENTER);
}
private JPanel baueFormular() {
JPanel formular = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int zeile = 0;
zeile = formularZeile(formular, c, zeile, "Produktnummer:", nummerAnzeige);
zeile = formularZeile(formular, c, zeile, "Bezeichnung: *", bezeichnungFeld);
zeile = formularZeile(formular, c, zeile, "Beschreibung:", beschreibungFeld);
zeile = formularZeile(formular, c, zeile, "Einzelpreis (netto): *", preisFeld);
zeile = formularZeile(formular, c, zeile, "Steuersatz: *", steuersatzWahl);
zeile = formularZeile(formular, c, zeile, "Einheit:", einheitFeld);
DocumentListener aenderungsListener = neuerAenderungsListener();
for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
feld.getDocument().addDocumentListener(aenderungsListener);
}
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton neu = new JButton("Neu");
neu.addActionListener(e -> leereFormular());
JButton speichern = new JButton("Speichern");
speichern.addActionListener(e -> speichere());
JButton loeschen = new JButton("Löschen");
loeschen.addActionListener(e -> loesche());
JButton export = new JButton("CSV-Export");
export.addActionListener(e -> exportiere());
knoepfe.add(neu);
knoepfe.add(speichern);
knoepfe.add(loeschen);
knoepfe.add(export);
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 2;
formular.add(knoepfe, c);
return formular;
}
private int formularZeile(JPanel formular, GridBagConstraints c, int zeile,
String beschriftung, JComponent feld) {
c.gridx = 0;
c.gridy = zeile;
c.gridwidth = 1;
c.weightx = 0;
formular.add(new JLabel(beschriftung), c);
c.gridx = 1;
c.weightx = 1;
formular.add(feld, c);
return zeile + 1;
}
private void speichere() {
try {
Produkt produkt = gewaehlteNummer == null
? new Produkt()
: service.findeProdukt(gewaehlteNummer);
produkt.setBezeichnung(bezeichnungFeld.getText().trim());
produkt.setBeschreibung(leerZuNull(beschreibungFeld.getText()));
produkt.setEinzelpreisNetto(parsePreis(preisFeld.getText()));
produkt.setSteuersatz(gewaehlterSteuersatz());
produkt.setEinheit(leerZuNull(einheitFeld.getText()));
Produkt gespeichert = gewaehlteNummer == null
? service.legeAn(produkt)
: service.aendere(produkt);
ungespeichert = false;
gewaehlteNummer = gespeichert.getProduktnummer();
nummerAnzeige.setText(gespeichert.getProduktnummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gespeichert. Produktnummer: "
+ gespeichert.getProduktnummer()), felder);
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), felder);
}
}
/** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */
private BigDecimal parsePreis(String text) {
String wert = text.trim().replace(',', '.');
if (wert.isEmpty()) {
throw new ValidierungsException("Einzelpreis",
"Das Pflichtfeld 'Einzelpreis (netto)' fehlt.");
}
try {
return new BigDecimal(wert).setScale(2, java.math.RoundingMode.HALF_UP);
} catch (NumberFormatException e) {
throw new ValidierungsException("Einzelpreis",
"Der 'Einzelpreis (netto)' ist keine gültige Zahl: " + text);
}
}
private BigDecimal gewaehlterSteuersatz() {
return switch (steuersatzWahl.getSelectedIndex()) {
case 0 -> new BigDecimal("0.19");
case 1 -> new BigDecimal("0.07");
default -> new BigDecimal("0.00");
};
}
private void loesche() {
if (gewaehlteNummer == null) {
return;
}
int antwort = JOptionPane.showConfirmDialog(this,
"Produkt " + gewaehlteNummer + " wirklich dauerhaft löschen?",
"Produkt löschen", JOptionPane.YES_NO_OPTION);
if (antwort != JOptionPane.YES_OPTION) {
return;
}
try {
service.loescheProdukt(gewaehlteNummer);
leereFormular();
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), felder);
} catch (LoeschAbgelehntException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), felder);
}
}
private void exportiere() {
JFileChooser auswahl = new JFileChooser();
auswahl.setSelectedFile(new java.io.File("produkte.csv"));
if (auswahl.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
csvExport.exportiereCsv(auswahl.getSelectedFile().toPath());
MeldungsAnzeige.zeige(this, Meldung.erfolg("Die Produktstammdaten wurden exportiert nach "
+ auswahl.getSelectedFile()), felder);
}
}
private void ladeAuswahl() {
int zeile = tabelle.getSelectedRow();
if (zeile < 0) {
return;
}
Produkt produkt = tabellenModel.produkte.get(zeile);
gewaehlteNummer = produkt.getProduktnummer();
nummerAnzeige.setText(produkt.getProduktnummer());
bezeichnungFeld.setText(produkt.getBezeichnung());
beschreibungFeld.setText(produkt.getBeschreibung() == null ? "" : produkt.getBeschreibung());
preisFeld.setText(produkt.getEinzelpreisNetto().toPlainString());
steuersatzWahl.setSelectedIndex(switch (produkt.getSteuersatz().stripTrailingZeros().toPlainString()) {
case "0.19" -> 0;
case "0.07" -> 1;
default -> 2;
});
einheitFeld.setText(produkt.getEinheit() == null ? "" : produkt.getEinheit());
ungespeichert = false;
}
private void leereFormular() {
gewaehlteNummer = null;
nummerAnzeige.setText("— neues Produkt —");
for (JTextField feld : List.of(bezeichnungFeld, beschreibungFeld, preisFeld, einheitFeld)) {
feld.setText("");
}
steuersatzWahl.setSelectedIndex(0);
tabelle.clearSelection();
ungespeichert = false;
}
private static String leerZuNull(String text) {
String wert = text.trim();
return wert.isEmpty() ? null : wert;
}
@Override
public boolean hatUngespeicherteAenderungen() {
return ungespeichert;
}
@Override
public void aktualisiere() {
String begriff = suchfeld.getText().trim();
tabellenModel.setze(begriff.isEmpty()
? service.alleSortiertNachBezeichnung()
: service.suche(begriff));
}
private DocumentListener neuerSuchListener() {
return new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
aktualisiere();
}
@Override
public void removeUpdate(DocumentEvent e) {
aktualisiere();
}
@Override
public void changedUpdate(DocumentEvent e) {
aktualisiere();
}
};
}
private DocumentListener neuerAenderungsListener() {
return new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
ungespeichert = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
ungespeichert = true;
}
};
}
private static final class ProduktTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN = {"Produktnummer", "Bezeichnung", "Einzelpreis (netto)", "Steuersatz", "Einheit"};
private List<Produkt> produkte = new ArrayList<>();
void setze(List<Produkt> neueProdukte) {
this.produkte = new ArrayList<>(neueProdukte);
fireTableDataChanged();
}
@Override
public int getRowCount() {
return produkte.size();
}
@Override
public int getColumnCount() {
return SPALTEN.length;
}
@Override
public String getColumnName(int spalte) {
return SPALTEN[spalte];
}
@Override
public Object getValueAt(int zeile, int spalte) {
Produkt produkt = produkte.get(zeile);
return switch (spalte) {
case 0 -> produkt.getProduktnummer();
case 1 -> produkt.getBezeichnung();
case 2 -> produkt.getEinzelpreisNetto().toPlainString() + " EUR";
case 3 -> produkt.getSteuersatz().multiply(new BigDecimal("100"))
.stripTrailingZeros().toPlainString() + " %";
default -> produkt.getEinheit() == null ? "" : produkt.getEinheit();
};
}
}
}

View File

@ -0,0 +1,170 @@
package de.team1.faktura.gui;
import de.team1.faktura.dokumente.DokumentService;
import de.team1.faktura.dokumente.Positionsangabe;
import de.team1.faktura.dokumente.Rechnung;
import de.team1.faktura.dokumente.StandardDokumentService;
import de.team1.faktura.dokumente.Summen;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* Dialogführung der geführten Rechnungserstellung (D-F-09 bis F-13):
* Schrittfolge, Vollständigkeitsprüfung je Schritt, Zusammenfassung und
* genau ein Speicheraufruf an den {@link DokumentService} (Gruppe A).
* GUI-frei und damit im Modultest ohne Oberfläche prüfbar.
*/
public class RechnungsWizardController {
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final List<WizardSchritt> REIHENFOLGE = List.of(WizardSchritt.values());
private final DokumentService dokumentService;
private final KundenService kundenService;
private final ProduktService produktService;
private final RechnungsWizardModel model = new RechnungsWizardModel();
private Meldung letzteMeldung;
private Rechnung gespeicherteRechnung;
public RechnungsWizardController(DokumentService dokumentService,
KundenService kundenService,
ProduktService produktService) {
this.dokumentService = dokumentService;
this.kundenService = kundenService;
this.produktService = produktService;
}
public RechnungsWizardModel getModel() {
return model;
}
public Meldung getLetzteMeldung() {
return letzteMeldung;
}
public Rechnung getGespeicherteRechnung() {
return gespeicherteRechnung;
}
/**
* Wechselt zum nächsten Schritt; bei unvollständiger Eingabe wird der
* Wechsel verhindert und die fehlende Eingabe benannt (F-10, Q-09).
*/
public boolean weiter() {
Meldung fehler = pruefeAktuellenSchritt();
if (fehler != null) {
letzteMeldung = fehler;
return false;
}
int index = REIHENFOLGE.indexOf(model.getAktuellerSchritt());
if (index < REIHENFOLGE.size() - 1) {
model.setAktuellerSchritt(REIHENFOLGE.get(index + 1));
}
letzteMeldung = null;
return true;
}
/** Rückkehr zum vorherigen Schritt ohne Verlust der Eingaben (F-11). */
public void zurueck() {
int index = REIHENFOLGE.indexOf(model.getAktuellerSchritt());
if (index > 0) {
model.setAktuellerSchritt(REIHENFOLGE.get(index - 1));
}
letzteMeldung = null;
}
/** Vollständigkeitsprüfung des aktuellen Schritts (F-10). */
private Meldung pruefeAktuellenSchritt() {
return switch (model.getAktuellerSchritt()) {
case KUNDE_WAEHLEN -> model.getKundenNr() == null
? Meldung.fehler("Kunde", "Bitte zuerst einen Kunden auswählen.")
: null;
case POSITIONEN_ERFASSEN -> pruefePositionen();
case DATEN_BESTAETIGEN -> model.getRechnungsdatum() == null
? Meldung.fehler("Rechnungsdatum", "Bitte ein Rechnungsdatum angeben.")
: null;
case ZUSAMMENFASSUNG, SPEICHERN -> null;
};
}
private Meldung pruefePositionen() {
if (model.getPositionen().isEmpty()) {
return Meldung.fehler("Position", "Bitte mindestens eine Position erfassen.");
}
for (PositionsEingabe position : model.getPositionen()) {
if (position.menge() <= 0) {
return Meldung.fehler("Menge", "Die Menge muss größer als 0 sein.");
}
}
return null;
}
/**
* Zusammenfassung für Schritt 4 (F-12): Kunde, Positionen, Mengen,
* Summen (vom {@link DokumentService} berechnet), Rechnungsdatum und
* Zahlungsziel.
*/
public String erzeugeZusammenfassung() {
StringBuilder text = new StringBuilder();
Kunde kunde = kundenService.findeKunde(model.getKundenNr());
text.append("Kunde: ")
.append(kunde != null ? kunde.getName() + " (" + kunde.getKundennummer() + ")"
: model.getKundenNr())
.append('\n');
text.append("Positionen:\n");
for (PositionsEingabe position : model.getPositionen()) {
Produkt produkt = produktService.findeProdukt(position.produktnummer());
String bezeichnung = produkt != null ? produkt.getBezeichnung() : position.produktnummer();
text.append(" ").append(position.menge()).append(" x ")
.append(bezeichnung).append(" (").append(position.produktnummer()).append(")\n");
}
Summen summen = dokumentService.berechneSummen(positionsangaben());
text.append("Summe netto: ").append(summen.netto().toPlainString()).append(" EUR\n");
text.append("Umsatzsteuer: ").append(summen.steuer().toPlainString()).append(" EUR\n");
text.append("Summe brutto: ").append(summen.brutto().toPlainString()).append(" EUR\n");
text.append("Rechnungsdatum: ").append(DATUM.format(model.getRechnungsdatum())).append('\n');
LocalDate zahlungsziel = model.getZahlungsziel() != null
? model.getZahlungsziel()
: model.getRechnungsdatum().plusDays(StandardDokumentService.STANDARD_ZAHLUNGSZIEL_TAGE);
text.append("Zahlungsziel: ").append(DATUM.format(zahlungsziel));
if (model.getZahlungsziel() == null) {
text.append(" (Standard: 14 Tage)");
}
return text.toString();
}
/**
* Löst genau einen Speicheraufruf am {@link DokumentService} aus (F-13);
* Validierungsfehler der Fachkomponente werden als Meldung mit dem
* betroffenen Feld dargestellt (F-05, F-16).
*/
public Meldung speichern() {
try {
gespeicherteRechnung = dokumentService.erstelleRechnung(
model.getKundenNr(), positionsangaben(),
model.getRechnungsdatum(), model.getZahlungsziel());
letzteMeldung = Meldung.erfolg("Die Rechnung " + gespeicherteRechnung.getBelegnummer()
+ " wurde gespeichert.");
} catch (ValidierungsException e) {
letzteMeldung = Meldung.fehler(e.getFeldname(), e.getMessage());
}
return letzteMeldung;
}
private List<Positionsangabe> positionsangaben() {
return model.getPositionen().stream()
.map(p -> new Positionsangabe(p.produktnummer(), p.menge()))
.toList();
}
}

View File

@ -0,0 +1,281 @@
package de.team1.faktura.gui;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.FlowLayout;
import java.awt.Window;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* Wizard-Dialog der geführten Rechnungserstellung mit genau fünf Schritten
* (D-F-09 bis F-13); Dialogführung und Validierung liegen im GUI-freien
* {@link RechnungsWizardController}.
*/
public class RechnungsWizardDialog extends JDialog {
private static final DateTimeFormatter DATUM = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final RechnungsWizardController controller;
private final KundenService kundenService;
private final ProduktService produktService;
private final CardLayout karten = new CardLayout();
private final JPanel kartenPanel = new JPanel(karten);
private final JTextField kundenSuche = new JTextField(18);
private final DefaultListModel<Kunde> kundenListenModel = new DefaultListModel<>();
private final JList<Kunde> kundenListe = new JList<>(kundenListenModel);
private final JComboBox<Produkt> produktWahl = new JComboBox<>();
private final JSpinner mengeWahl = new JSpinner(new SpinnerNumberModel(1, 1, 99999, 1));
private final DefaultListModel<String> positionsListenModel = new DefaultListModel<>();
private final JList<String> positionsListe = new JList<>(positionsListenModel);
private final JTextField rechnungsdatumFeld = new JTextField(10);
private final JTextField zahlungszielFeld = new JTextField(10);
private final JTextArea zusammenfassung = new JTextArea(14, 50);
private final JButton zurueckKnopf = new JButton("< Zurück");
private final JButton weiterKnopf = new JButton("Weiter >");
private final JButton speichernKnopf = new JButton("Speichern");
private final JLabel schrittAnzeige = new JLabel();
public RechnungsWizardDialog(Window besitzer, RechnungsWizardController controller,
KundenService kundenService, ProduktService produktService) {
super(besitzer, "Geführte Rechnungserstellung", ModalityType.APPLICATION_MODAL);
this.controller = controller;
this.kundenService = kundenService;
this.produktService = produktService;
baueOberflaeche();
ladeKunden("");
ladeProdukte();
zeigeSchritt();
pack();
setLocationRelativeTo(besitzer);
}
private void baueOberflaeche() {
setLayout(new BorderLayout(8, 8));
add(schrittAnzeige, BorderLayout.NORTH);
kartenPanel.add(baueSchrittKunde(), WizardSchritt.KUNDE_WAEHLEN.name());
kartenPanel.add(baueSchrittPositionen(), WizardSchritt.POSITIONEN_ERFASSEN.name());
kartenPanel.add(baueSchrittDaten(), WizardSchritt.DATEN_BESTAETIGEN.name());
kartenPanel.add(baueSchrittZusammenfassung(), WizardSchritt.ZUSAMMENFASSUNG.name());
kartenPanel.add(baueSchrittSpeichern(), WizardSchritt.SPEICHERN.name());
add(kartenPanel, BorderLayout.CENTER);
JPanel knoepfe = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton abbrechen = new JButton("Abbrechen");
abbrechen.addActionListener(e -> dispose());
zurueckKnopf.addActionListener(e -> {
controller.zurueck();
zeigeSchritt();
});
weiterKnopf.addActionListener(e -> weiter());
speichernKnopf.addActionListener(e -> speichere());
knoepfe.add(abbrechen);
knoepfe.add(zurueckKnopf);
knoepfe.add(weiterKnopf);
knoepfe.add(speichernKnopf);
add(knoepfe, BorderLayout.SOUTH);
}
/** Schritt 1: Kunde auswählen (F-09). */
private JPanel baueSchrittKunde() {
JPanel panel = new JPanel(new BorderLayout(5, 5));
JPanel suche = new JPanel(new FlowLayout(FlowLayout.LEFT));
suche.add(new JLabel("Suche:"));
suche.add(kundenSuche);
kundenSuche.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
ladeKunden(kundenSuche.getText().trim());
}
@Override
public void removeUpdate(DocumentEvent e) {
ladeKunden(kundenSuche.getText().trim());
}
@Override
public void changedUpdate(DocumentEvent e) {
ladeKunden(kundenSuche.getText().trim());
}
});
panel.add(suche, BorderLayout.NORTH);
kundenListe.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
kundenListe.addListSelectionListener(e -> {
Kunde kunde = kundenListe.getSelectedValue();
controller.getModel().setKundenNr(kunde == null ? null : kunde.getKundennummer());
});
panel.add(new JScrollPane(kundenListe), BorderLayout.CENTER);
return panel;
}
/** Schritt 2: mindestens eine Produktposition mit Menge erfassen (F-09). */
private JPanel baueSchrittPositionen() {
JPanel panel = new JPanel(new BorderLayout(5, 5));
JPanel eingabe = new JPanel(new FlowLayout(FlowLayout.LEFT));
eingabe.add(new JLabel("Produkt:"));
eingabe.add(produktWahl);
eingabe.add(new JLabel("Menge:"));
eingabe.add(mengeWahl);
JButton hinzufuegen = new JButton("Hinzufügen");
hinzufuegen.addActionListener(e -> {
Produkt produkt = (Produkt) produktWahl.getSelectedItem();
if (produkt == null) {
return;
}
int menge = (Integer) mengeWahl.getValue();
controller.getModel().fuegePositionHinzu(
new PositionsEingabe(produkt.getProduktnummer(), menge));
positionsListenModel.addElement(menge + " x " + produkt.getBezeichnung()
+ " (" + produkt.getProduktnummer() + ")");
});
eingabe.add(hinzufuegen);
JButton entfernen = new JButton("Entfernen");
entfernen.addActionListener(e -> {
int index = positionsListe.getSelectedIndex();
if (index >= 0) {
controller.getModel().entfernePosition(index);
positionsListenModel.remove(index);
}
});
eingabe.add(entfernen);
panel.add(eingabe, BorderLayout.NORTH);
panel.add(new JScrollPane(positionsListe), BorderLayout.CENTER);
return panel;
}
/** Schritt 3: Rechnungsdatum und Zahlungsziel bestätigen (F-09). */
private JPanel baueSchrittDaten() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT));
panel.add(new JLabel("Rechnungsdatum (TT.MM.JJJJ): *"));
rechnungsdatumFeld.setText(DATUM.format(LocalDate.now()));
panel.add(rechnungsdatumFeld);
panel.add(new JLabel("Zahlungsziel (leer = 14 Tage):"));
panel.add(zahlungszielFeld);
return panel;
}
/** Schritt 4: Zusammenfassung prüfen (F-12). */
private JPanel baueSchrittZusammenfassung() {
JPanel panel = new JPanel(new BorderLayout());
zusammenfassung.setEditable(false);
panel.add(new JScrollPane(zusammenfassung), BorderLayout.CENTER);
return panel;
}
/** Schritt 5: speichern (F-13). */
private JPanel baueSchrittSpeichern() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT));
panel.add(new JLabel("Alle Angaben sind erfasst. Klicken Sie auf „Speichern“, "
+ "um die Rechnung zu erstellen."));
return panel;
}
private void weiter() {
if (controller.getModel().getAktuellerSchritt() == WizardSchritt.DATEN_BESTAETIGEN
&& !uebernehmeDaten()) {
return;
}
if (!controller.weiter()) {
MeldungsAnzeige.zeige(this, controller.getLetzteMeldung(), null);
return;
}
zeigeSchritt();
}
/** Übernimmt die Datumsfelder in das Modell; bei Formatfehlern Meldung (F-10, Q-09). */
private boolean uebernehmeDaten() {
try {
controller.getModel().setRechnungsdatum(
LocalDate.parse(rechnungsdatumFeld.getText().trim(), DATUM));
} catch (DateTimeParseException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler("Rechnungsdatum",
"Das 'Rechnungsdatum' ist ungültig. Format: TT.MM.JJJJ"), null);
return false;
}
String zahlungsziel = zahlungszielFeld.getText().trim();
if (zahlungsziel.isEmpty()) {
controller.getModel().setZahlungsziel(null);
} else {
try {
controller.getModel().setZahlungsziel(LocalDate.parse(zahlungsziel, DATUM));
} catch (DateTimeParseException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler("Zahlungsziel",
"Das 'Zahlungsziel' ist ungültig. Format: TT.MM.JJJJ"), null);
return false;
}
}
return true;
}
private void speichere() {
Meldung meldung = controller.speichern();
MeldungsAnzeige.zeige(this, meldung, null);
if (meldung.typ() == MeldungsTyp.ERFOLG) {
dispose();
}
}
private void zeigeSchritt() {
WizardSchritt schritt = controller.getModel().getAktuellerSchritt();
if (schritt == WizardSchritt.ZUSAMMENFASSUNG) {
zusammenfassung.setText(controller.erzeugeZusammenfassung());
}
karten.show(kartenPanel, schritt.name());
schrittAnzeige.setText(" Schritt " + (schritt.ordinal() + 1) + " von 5: " + schrittName(schritt));
zurueckKnopf.setEnabled(schritt.ordinal() > 0);
weiterKnopf.setEnabled(schritt != WizardSchritt.SPEICHERN);
speichernKnopf.setEnabled(schritt == WizardSchritt.SPEICHERN);
}
private static String schrittName(WizardSchritt schritt) {
return switch (schritt) {
case KUNDE_WAEHLEN -> "Kunde auswählen";
case POSITIONEN_ERFASSEN -> "Positionen erfassen";
case DATEN_BESTAETIGEN -> "Rechnungsdatum und Zahlungsziel";
case ZUSAMMENFASSUNG -> "Zusammenfassung prüfen";
case SPEICHERN -> "Speichern";
};
}
private void ladeKunden(String suchbegriff) {
kundenListenModel.clear();
for (Kunde kunde : kundenService.suche(suchbegriff)) {
kundenListenModel.addElement(kunde);
}
}
private void ladeProdukte() {
produktWahl.setModel(new DefaultComboBoxModel<>(
produktService.suche("").toArray(new Produkt[0])));
}
}

View File

@ -0,0 +1,64 @@
package de.team1.faktura.gui;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* UI-Zustandsmodell der geführten Rechnungserstellung (Gruppe D,
* Kapitel 6.1). Frei von GUI-Framework-Klassen und damit ohne
* Oberfläche testbar.
*/
public class RechnungsWizardModel {
private WizardSchritt aktuellerSchritt = WizardSchritt.KUNDE_WAEHLEN;
private String kundenNr;
private final List<PositionsEingabe> positionen = new ArrayList<>();
private LocalDate rechnungsdatum = LocalDate.now();
private LocalDate zahlungsziel;
public WizardSchritt getAktuellerSchritt() {
return aktuellerSchritt;
}
void setAktuellerSchritt(WizardSchritt schritt) {
this.aktuellerSchritt = schritt;
}
public String getKundenNr() {
return kundenNr;
}
public void setKundenNr(String kundenNr) {
this.kundenNr = kundenNr;
}
public List<PositionsEingabe> getPositionen() {
return positionen;
}
public void fuegePositionHinzu(PositionsEingabe position) {
positionen.add(position);
}
public void entfernePosition(int index) {
positionen.remove(index);
}
public LocalDate getRechnungsdatum() {
return rechnungsdatum;
}
public void setRechnungsdatum(LocalDate rechnungsdatum) {
this.rechnungsdatum = rechnungsdatum;
}
/** {@code null} = Standard-Zahlungsziel der Gruppe A (GR-06: +14 Tage). */
public LocalDate getZahlungsziel() {
return zahlungsziel;
}
public void setZahlungsziel(LocalDate zahlungsziel) {
this.zahlungsziel = zahlungsziel;
}
}

View File

@ -0,0 +1,31 @@
package de.team1.faktura.gui;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import java.util.List;
/**
* Controller der Stammdaten-Ansichten (D-F-03): delegiert Suchanfragen an
* die Dienste der Gruppen B und C; die GUI rechnet und filtert selbst nicht.
*/
public class StammdatenController {
private final KundenService kundenService;
private final ProduktService produktService;
public StammdatenController(KundenService kundenService, ProduktService produktService) {
this.kundenService = kundenService;
this.produktService = produktService;
}
public List<Kunde> sucheKunden(String suchbegriff) {
return kundenService.suche(suchbegriff);
}
public List<Produkt> sucheProdukte(String suchbegriff) {
return produktService.suche(suchbegriff);
}
}

View File

@ -0,0 +1,12 @@
package de.team1.faktura.gui;
/**
* Die fünf Schritte der geführten Rechnungserstellung (D-F-09, BA-13).
*/
public enum WizardSchritt {
KUNDE_WAEHLEN,
POSITIONEN_ERFASSEN,
DATEN_BESTAETIGEN,
ZUSAMMENFASSUNG,
SPEICHERN
}

View File

@ -0,0 +1,43 @@
package de.team1.faktura.kunden;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Fortlaufende Kundennummern im Format {@code K-NNNNNN} (Präfix, führende
* Nullen) auf Basis der höchsten bisher vergebenen Nummer (C, Kapitel 4).
*/
public class EinfacherKundennummernGenerator implements KundennummernGenerator {
private static final Pattern FORMAT = Pattern.compile("K-(\\d{6})");
private int zaehler;
/** @param zaehler Wert der nächsten zu vergebenden Nummer (TC-02: Zähler 7 → {@code K-000007}). */
public EinfacherKundennummernGenerator(int zaehler) {
this.zaehler = zaehler;
}
/** Initialisiert den Zähler aus der höchsten bereits vergebenen Nummer im Bestand. */
public static EinfacherKundennummernGenerator ausRepository(KundenRepository repository) {
int hoechste = repository.alleSortiertNachName().stream()
.map(Kunde::getKundennummer)
.mapToInt(EinfacherKundennummernGenerator::nummernWert)
.max()
.orElse(0);
return new EinfacherKundennummernGenerator(hoechste + 1);
}
private static int nummernWert(String kundennummer) {
if (kundennummer == null) {
return 0;
}
Matcher matcher = FORMAT.matcher(kundennummer);
return matcher.matches() ? Integer.parseInt(matcher.group(1)) : 0;
}
@Override
public synchronized String naechsteNummer() {
return String.format("K-%06d", zaehler++);
}
}

View File

@ -0,0 +1,92 @@
package de.team1.faktura.kunden;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.team1.faktura.gemeinsam.JsonPersistenz;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* JSON-Datei-Persistenz der Kunden (IF-01). Der Bestand wird vollständig
* im Speicher gehalten (Q-01/Q-02: bis 5.000 Kunden, Suche 1 s) und bei
* jeder Änderung in die Datei geschrieben.
*/
public class JsonKundenRepository implements KundenRepository {
private final Path datei;
private final ObjectMapper mapper = JsonPersistenz.mapper();
private final List<Kunde> kunden = new ArrayList<>();
public JsonKundenRepository(Path datei) {
this.datei = datei;
lade();
}
private void lade() {
if (!Files.exists(datei)) {
return;
}
try {
kunden.addAll(mapper.readValue(datei.toFile(), new TypeReference<List<Kunde>>() { }));
} catch (IOException e) {
throw new UncheckedIOException("Kundenbestand konnte nicht gelesen werden: " + datei, e);
}
}
private void schreibe() {
try {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
mapper.writeValue(datei.toFile(), kunden);
} catch (IOException e) {
throw new UncheckedIOException("Kundenbestand konnte nicht gespeichert werden: " + datei, e);
}
}
@Override
public Kunde speichere(Kunde kunde) {
kunden.removeIf(k -> k.getKundennummer().equals(kunde.getKundennummer()));
kunden.add(kunde);
schreibe();
return kunde;
}
@Override
public void loesche(String kundennummer) {
kunden.removeIf(k -> k.getKundennummer().equals(kundennummer));
schreibe();
}
@Override
public Kunde findeNachNummer(String kundennummer) {
return kunden.stream()
.filter(k -> k.getKundennummer().equals(kundennummer))
.findFirst()
.orElse(null);
}
@Override
public List<Kunde> alleSortiertNachName() {
return kunden.stream()
.sorted(Comparator.comparing(Kunde::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
}
@Override
public List<Kunde> suche(String suchbegriff) {
String begriff = suchbegriff == null ? "" : suchbegriff.toLowerCase(Locale.ROOT);
return kunden.stream()
.filter(k -> k.getName().toLowerCase(Locale.ROOT).contains(begriff)
|| k.getKundennummer().toLowerCase(Locale.ROOT).contains(begriff))
.sorted(Comparator.comparing(Kunde::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
}
}

View File

@ -0,0 +1,113 @@
package de.team1.faktura.kunden;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/**
* Kundenstammdaten (Pflichtenheft Gruppe C, Kapitel 6.1).
* Kundennummer und PLZ werden als {@code String} geführt (führende Nullen).
* Die Kundennummer ist nach Vergabe unveränderlich (C-F-07).
*/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE,
setterVisibility = JsonAutoDetect.Visibility.NONE)
public class Kunde {
private String kundennummer;
private String name;
private String strasse;
private String plz;
private String ort;
private String eMail;
private String telefon;
private String ustIdNr;
public Kunde() {
}
public Kunde(String name, String strasse, String plz, String ort) {
this.name = name;
this.strasse = strasse;
this.plz = plz;
this.ort = ort;
}
public String getKundennummer() {
return kundennummer;
}
/** Einmalige Vergabe durch das System; jede spätere Änderung wird abgelehnt (C-F-07). */
public void setKundennummer(String kundennummer) {
if (this.kundennummer != null && !this.kundennummer.equals(kundennummer)) {
throw new IllegalArgumentException(
"Die Kundennummer ist nach der Vergabe unveränderlich (C-F-07).");
}
this.kundennummer = kundennummer;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getStrasse() {
return strasse;
}
public void setStrasse(String strasse) {
this.strasse = strasse;
}
public String getPlz() {
return plz;
}
public void setPlz(String plz) {
this.plz = plz;
}
public String getOrt() {
return ort;
}
public void setOrt(String ort) {
this.ort = ort;
}
public String getEMail() {
return eMail;
}
public void setEMail(String eMail) {
this.eMail = eMail;
}
public String getTelefon() {
return telefon;
}
public void setTelefon(String telefon) {
this.telefon = telefon;
}
public String getUstIdNr() {
return ustIdNr;
}
public void setUstIdNr(String ustIdNr) {
this.ustIdNr = ustIdNr;
}
/** Anschrift einzeilig, z. B. für die Beleg-Übernahme durch Gruppe A. */
public String anschrift() {
return strasse + ", " + plz + " " + ort;
}
@Override
public String toString() {
return (kundennummer != null ? kundennummer + " — " : "") + name;
}
}

View File

@ -0,0 +1,36 @@
package de.team1.faktura.kunden;
import de.team1.faktura.gemeinsam.Csv;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static de.team1.faktura.gemeinsam.Csv.TRENNZEICHEN;
import static de.team1.faktura.gemeinsam.Csv.feld;
/**
* Export aller Kundenstammdaten als CSV (C-F-15, Q-08):
* UTF-8, Semikolon-getrennt, mit Kopfzeile, alle Attribute.
*/
public class KundenCsvExport {
private final KundenRepository repository;
public KundenCsvExport(KundenRepository repository) {
this.repository = repository;
}
public void exportiereCsv(Path zielDatei) {
List<String> zeilen = new ArrayList<>();
zeilen.add(String.join(TRENNZEICHEN,
"kundennummer", "name", "strasse", "plz", "ort", "eMail", "telefon", "ustIdNr"));
for (Kunde k : repository.alleSortiertNachName()) {
zeilen.add(String.join(TRENNZEICHEN,
feld(k.getKundennummer()), feld(k.getName()), feld(k.getStrasse()),
feld(k.getPlz()), feld(k.getOrt()), feld(k.getEMail()),
feld(k.getTelefon()), feld(k.getUstIdNr())));
}
Csv.schreibe(zielDatei, zeilen);
}
}

View File

@ -0,0 +1,11 @@
package de.team1.faktura.kunden;
/**
* Löschsperre GR-04: von Gruppe A bereitgestellt, von Gruppe C vor jedem
* Löschvorgang genutzt (C-F-10).
*/
public interface KundenReferenzPruefung {
/** Anzahl aktiver und archivierter Dokumente, die den Kunden referenzieren. */
int anzahlVerknuepfterDokumente(String kundennummer);
}

View File

@ -0,0 +1,21 @@
package de.team1.faktura.kunden;
import java.util.List;
/**
* Persistenz der Kundenstammdaten im lokalen Dateisystem (IF-01, C Kapitel 6.2).
*/
public interface KundenRepository {
Kunde speichere(Kunde kunde);
void loesche(String kundennummer);
/** Liefert den Kunden zur Nummer oder {@code null}. */
Kunde findeNachNummer(String kundennummer);
List<Kunde> alleSortiertNachName();
/** Suche über Name ODER Kundennummer (Teilstring, case-insensitive). */
List<Kunde> suche(String suchbegriff);
}

View File

@ -0,0 +1,16 @@
package de.team1.faktura.kunden;
import java.util.List;
/**
* Lesender Zugriff auf Kundenstammdaten von Gruppe C implementiert,
* von Gruppe A (Dokumentenzyklus) und Gruppe D (GUI) genutzt (C-F-14).
*/
public interface KundenService {
/** Liefert den Kunden zur Kundennummer oder {@code null}, wenn nicht vorhanden. */
Kunde findeKunde(String kundennummer);
/** Volltextsuche über Name oder Kundennummer (Teilstring, case-insensitive, C-F-12). */
List<Kunde> suche(String suchbegriff);
}

View File

@ -0,0 +1,90 @@
package de.team1.faktura.kunden;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import java.util.List;
/**
* Fachlogik der Kundenverwaltung (Pflichtenheft Gruppe C):
* Validierung (F-03, F-04), Nummernvergabe (F-02), Löschsperre GR-04
* (F-08F-10) sowie lesender Zugriff für Gruppe A (F-14).
*/
public class KundenVerwaltungsService implements KundenService {
private final KundenRepository repository;
private final KundennummernGenerator nummernGenerator;
private final KundenReferenzPruefung referenzPruefung;
public KundenVerwaltungsService(KundenRepository repository,
KundennummernGenerator nummernGenerator,
KundenReferenzPruefung referenzPruefung) {
this.repository = repository;
this.nummernGenerator = nummernGenerator;
this.referenzPruefung = referenzPruefung;
}
/** Legt einen neuen Kunden an und vergibt die Kundennummer (F-01, F-02). */
public Kunde legeAn(Kunde kunde) {
validiere(kunde);
kunde.setKundennummer(nummernGenerator.naechsteNummer());
return repository.speichere(kunde);
}
/** Ändert einen bestehenden Kunden; die Pflichtfeldprüfung gilt unverändert (F-05). */
public Kunde aendere(Kunde kunde) {
if (kunde.getKundennummer() == null) {
throw new ValidierungsException("Kundennummer", "Der Kunde wurde noch nicht angelegt.");
}
validiere(kunde);
return repository.speichere(kunde);
}
/**
* Löscht einen Kunden ohne verknüpfte Dokumente (F-08); bei verknüpften
* Dokumenten wird der Vorgang mit Angabe der Anzahl abgelehnt (F-09, GR-04).
*/
public void loescheKunde(String kundennummer) {
int anzahl = referenzPruefung.anzahlVerknuepfterDokumente(kundennummer);
if (anzahl > 0) {
throw new LoeschAbgelehntException(
"Der Kunde " + kundennummer + " kann nicht gelöscht werden: "
+ anzahl + " verknüpfte Dokumente vorhanden (GR-04).");
}
repository.loesche(kundennummer);
}
public List<Kunde> alleSortiertNachName() {
return repository.alleSortiertNachName();
}
@Override
public List<Kunde> suche(String suchbegriff) {
return repository.suche(suchbegriff);
}
@Override
public Kunde findeKunde(String kundennummer) {
return repository.findeNachNummer(kundennummer);
}
/** Pflichtfeld- und Formatprüfung (F-03, F-04); benennt das betroffene Feld (Q-09). */
private void validiere(Kunde kunde) {
pruefePflichtfeld(kunde.getName(), "Name");
pruefePflichtfeld(kunde.getStrasse(), "Straße");
pruefePflichtfeld(kunde.getPlz(), "PLZ");
pruefePflichtfeld(kunde.getOrt(), "Ort");
String eMail = kunde.getEMail();
if (eMail != null && !eMail.isBlank() && !eMail.matches(".+@.+")) {
throw new ValidierungsException("E-Mail",
"Das Feld 'E-Mail' hat ein ungültiges Format: " + eMail);
}
}
private void pruefePflichtfeld(String wert, String feldname) {
if (wert == null || wert.isBlank()) {
throw new ValidierungsException(feldname,
"Das Pflichtfeld '" + feldname + "' fehlt.");
}
}
}

View File

@ -0,0 +1,10 @@
package de.team1.faktura.kunden;
/**
* Vergabe eindeutiger, fortlaufender Kundennummern (C-F-02).
*/
public interface KundennummernGenerator {
/** Liefert die nächste fortlaufende Kundennummer, z. B. {@code "K-000017"}. */
String naechsteNummer();
}

View File

@ -0,0 +1,43 @@
package de.team1.faktura.produkte;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Fortlaufende Produktnummern im Format {@code P-NNNNNN} (Präfix, führende
* Nullen) auf Basis der höchsten bisher vergebenen Nummer (B, Kapitel 4).
*/
public class EinfacherProduktnummernGenerator implements ProduktnummernGenerator {
private static final Pattern FORMAT = Pattern.compile("P-(\\d{6})");
private int zaehler;
/** @param zaehler Wert der nächsten zu vergebenden Nummer (TC-02: Zähler 7 → {@code P-000007}). */
public EinfacherProduktnummernGenerator(int zaehler) {
this.zaehler = zaehler;
}
/** Initialisiert den Zähler aus der höchsten bereits vergebenen Nummer im Bestand. */
public static EinfacherProduktnummernGenerator ausRepository(ProduktRepository repository) {
int hoechste = repository.alleSortiertNachBezeichnung().stream()
.map(Produkt::getProduktnummer)
.mapToInt(EinfacherProduktnummernGenerator::nummernWert)
.max()
.orElse(0);
return new EinfacherProduktnummernGenerator(hoechste + 1);
}
private static int nummernWert(String produktnummer) {
if (produktnummer == null) {
return 0;
}
Matcher matcher = FORMAT.matcher(produktnummer);
return matcher.matches() ? Integer.parseInt(matcher.group(1)) : 0;
}
@Override
public synchronized String naechsteNummer() {
return String.format("P-%06d", zaehler++);
}
}

View File

@ -0,0 +1,92 @@
package de.team1.faktura.produkte;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.team1.faktura.gemeinsam.JsonPersistenz;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* JSON-Datei-Persistenz der Produkte (IF-01). Der Bestand wird vollständig
* im Speicher gehalten (Q-01/Q-02: bis 5.000 Produkte, Suche 1 s) und bei
* jeder Änderung in die Datei geschrieben.
*/
public class JsonProduktRepository implements ProduktRepository {
private final Path datei;
private final ObjectMapper mapper = JsonPersistenz.mapper();
private final List<Produkt> produkte = new ArrayList<>();
public JsonProduktRepository(Path datei) {
this.datei = datei;
lade();
}
private void lade() {
if (!Files.exists(datei)) {
return;
}
try {
produkte.addAll(mapper.readValue(datei.toFile(), new TypeReference<List<Produkt>>() { }));
} catch (IOException e) {
throw new UncheckedIOException("Produktbestand konnte nicht gelesen werden: " + datei, e);
}
}
private void schreibe() {
try {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
mapper.writeValue(datei.toFile(), produkte);
} catch (IOException e) {
throw new UncheckedIOException("Produktbestand konnte nicht gespeichert werden: " + datei, e);
}
}
@Override
public Produkt speichere(Produkt produkt) {
produkte.removeIf(p -> p.getProduktnummer().equals(produkt.getProduktnummer()));
produkte.add(produkt);
schreibe();
return produkt;
}
@Override
public void loesche(String produktnummer) {
produkte.removeIf(p -> p.getProduktnummer().equals(produktnummer));
schreibe();
}
@Override
public Produkt findeNachNummer(String produktnummer) {
return produkte.stream()
.filter(p -> p.getProduktnummer().equals(produktnummer))
.findFirst()
.orElse(null);
}
@Override
public List<Produkt> alleSortiertNachBezeichnung() {
return produkte.stream()
.sorted(Comparator.comparing(Produkt::getBezeichnung, String.CASE_INSENSITIVE_ORDER))
.toList();
}
@Override
public List<Produkt> suche(String suchbegriff) {
String begriff = suchbegriff == null ? "" : suchbegriff.toLowerCase(Locale.ROOT);
return produkte.stream()
.filter(p -> p.getBezeichnung().toLowerCase(Locale.ROOT).contains(begriff)
|| p.getProduktnummer().toLowerCase(Locale.ROOT).contains(begriff))
.sorted(Comparator.comparing(Produkt::getBezeichnung, String.CASE_INSENSITIVE_ORDER))
.toList();
}
}

View File

@ -0,0 +1,92 @@
package de.team1.faktura.produkte;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import java.math.BigDecimal;
/**
* Produktstammdaten (Pflichtenheft Gruppe B, Kapitel 6.1).
* Geldbeträge als {@code BigDecimal} (Scale 2), Steuersatz als Faktor
* (zulässig: 0.00, 0.07, 0.19). Die Produktnummer ist nach Vergabe
* unveränderlich (B-F-07).
*/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE,
setterVisibility = JsonAutoDetect.Visibility.NONE)
public class Produkt {
private String produktnummer;
private String bezeichnung;
private String beschreibung;
private BigDecimal einzelpreisNetto;
private BigDecimal steuersatz;
private String einheit;
public Produkt() {
}
public Produkt(String bezeichnung, BigDecimal einzelpreisNetto, BigDecimal steuersatz) {
this.bezeichnung = bezeichnung;
this.einzelpreisNetto = einzelpreisNetto;
this.steuersatz = steuersatz;
}
public String getProduktnummer() {
return produktnummer;
}
/** Einmalige Vergabe durch das System; jede spätere Änderung wird abgelehnt (B-F-07). */
public void setProduktnummer(String produktnummer) {
if (this.produktnummer != null && !this.produktnummer.equals(produktnummer)) {
throw new IllegalArgumentException(
"Die Produktnummer ist nach der Vergabe unveränderlich (B-F-07).");
}
this.produktnummer = produktnummer;
}
public String getBezeichnung() {
return bezeichnung;
}
public void setBezeichnung(String bezeichnung) {
this.bezeichnung = bezeichnung;
}
public String getBeschreibung() {
return beschreibung;
}
public void setBeschreibung(String beschreibung) {
this.beschreibung = beschreibung;
}
public BigDecimal getEinzelpreisNetto() {
return einzelpreisNetto;
}
public void setEinzelpreisNetto(BigDecimal einzelpreisNetto) {
this.einzelpreisNetto = einzelpreisNetto;
}
public BigDecimal getSteuersatz() {
return steuersatz;
}
public void setSteuersatz(BigDecimal steuersatz) {
this.steuersatz = steuersatz;
}
public String getEinheit() {
return einheit;
}
public void setEinheit(String einheit) {
this.einheit = einheit;
}
@Override
public String toString() {
return (produktnummer != null ? produktnummer + " — " : "") + bezeichnung;
}
}

View File

@ -0,0 +1,37 @@
package de.team1.faktura.produkte;
import de.team1.faktura.gemeinsam.Csv;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static de.team1.faktura.gemeinsam.Csv.TRENNZEICHEN;
import static de.team1.faktura.gemeinsam.Csv.feld;
/**
* Export aller Produktstammdaten als CSV (B-F-15, Q-08):
* UTF-8, Semikolon-getrennt, mit Kopfzeile, alle Attribute.
*/
public class ProduktCsvExport {
private final ProduktRepository repository;
public ProduktCsvExport(ProduktRepository repository) {
this.repository = repository;
}
public void exportiereCsv(Path zielDatei) {
List<String> zeilen = new ArrayList<>();
zeilen.add(String.join(TRENNZEICHEN,
"produktnummer", "bezeichnung", "beschreibung", "einzelpreisNetto", "steuersatz", "einheit"));
for (Produkt p : repository.alleSortiertNachBezeichnung()) {
zeilen.add(String.join(TRENNZEICHEN,
feld(p.getProduktnummer()), feld(p.getBezeichnung()), feld(p.getBeschreibung()),
feld(p.getEinzelpreisNetto() == null ? null : p.getEinzelpreisNetto().toPlainString()),
feld(p.getSteuersatz() == null ? null : p.getSteuersatz().toPlainString()),
feld(p.getEinheit())));
}
Csv.schreibe(zielDatei, zeilen);
}
}

View File

@ -0,0 +1,11 @@
package de.team1.faktura.produkte;
/**
* Löschsperre für referenzierte Produkte: von Gruppe A bereitgestellt,
* von Gruppe B vor jedem Löschvorgang genutzt (B-F-10).
*/
public interface ProduktReferenzPruefung {
/** {@code true}, wenn das Produkt in mindestens einer Dokumentposition referenziert wird. */
boolean istProduktReferenziert(String produktnummer);
}

View File

@ -0,0 +1,21 @@
package de.team1.faktura.produkte;
import java.util.List;
/**
* Persistenz der Produktstammdaten im lokalen Dateisystem (IF-01, B Kapitel 6.2).
*/
public interface ProduktRepository {
Produkt speichere(Produkt produkt);
void loesche(String produktnummer);
/** Liefert das Produkt zur Nummer oder {@code null}. */
Produkt findeNachNummer(String produktnummer);
List<Produkt> alleSortiertNachBezeichnung();
/** Suche über Bezeichnung ODER Produktnummer (Teilstring, case-insensitive). */
List<Produkt> suche(String suchbegriff);
}

View File

@ -0,0 +1,16 @@
package de.team1.faktura.produkte;
import java.util.List;
/**
* Lesender Zugriff auf Produktstammdaten von Gruppe B implementiert,
* von Gruppe A (Dokumentenzyklus) und Gruppe D (GUI) genutzt (B-F-14).
*/
public interface ProduktService {
/** Liefert das Produkt zur Produktnummer oder {@code null}, wenn nicht vorhanden. */
Produkt findeProdukt(String produktnummer);
/** Suche über Bezeichnung oder Produktnummer (Teilstring, case-insensitive, B-F-12). */
List<Produkt> suche(String suchbegriff);
}

View File

@ -0,0 +1,105 @@
package de.team1.faktura.produkte;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import java.math.BigDecimal;
import java.util.List;
/**
* Fachlogik der Produktverwaltung (Pflichtenheft Gruppe B):
* Validierung (F-03, F-04), Nummernvergabe (F-02), Löschsperre
* (F-08F-10) sowie lesender Zugriff für Gruppe A (F-14).
*/
public class ProduktVerwaltungsService implements ProduktService {
/** Zulässige Steuersätze als Faktor (B-F-03). */
private static final List<BigDecimal> ZULAESSIGE_STEUERSAETZE = List.of(
new BigDecimal("0.00"), new BigDecimal("0.07"), new BigDecimal("0.19"));
private final ProduktRepository repository;
private final ProduktnummernGenerator nummernGenerator;
private final ProduktReferenzPruefung referenzPruefung;
public ProduktVerwaltungsService(ProduktRepository repository,
ProduktnummernGenerator nummernGenerator,
ProduktReferenzPruefung referenzPruefung) {
this.repository = repository;
this.nummernGenerator = nummernGenerator;
this.referenzPruefung = referenzPruefung;
}
/** Legt ein neues Produkt an und vergibt die Produktnummer (F-01, F-02). */
public Produkt legeAn(Produkt produkt) {
validiere(produkt);
produkt.setProduktnummer(nummernGenerator.naechsteNummer());
return repository.speichere(produkt);
}
/**
* Ändert ein bestehendes Produkt (F-05). Bereits erstellte Dokumente bleiben
* unverändert, da Gruppe A Preis und Steuersatz als Snapshot ablegt (F-06).
*/
public Produkt aendere(Produkt produkt) {
if (produkt.getProduktnummer() == null) {
throw new ValidierungsException("Produktnummer", "Das Produkt wurde noch nicht angelegt.");
}
validiere(produkt);
return repository.speichere(produkt);
}
/**
* Löscht ein nicht referenziertes Produkt (F-08); referenzierte Produkte
* werden mit Hinweis abgelehnt (F-09, F-10).
*/
public void loescheProdukt(String produktnummer) {
if (referenzPruefung.istProduktReferenziert(produktnummer)) {
throw new LoeschAbgelehntException(
"Das Produkt " + produktnummer + " kann nicht gelöscht werden: "
+ "es wird in Dokumenten verwendet.");
}
repository.loesche(produktnummer);
}
public List<Produkt> alleSortiertNachBezeichnung() {
return repository.alleSortiertNachBezeichnung();
}
@Override
public List<Produkt> suche(String suchbegriff) {
return repository.suche(suchbegriff);
}
@Override
public Produkt findeProdukt(String produktnummer) {
return repository.findeNachNummer(produktnummer);
}
/** Pflichtfeld- und Wertebereichsprüfung (F-03, F-04); benennt das betroffene Feld (Q-09). */
private void validiere(Produkt produkt) {
if (produkt.getBezeichnung() == null || produkt.getBezeichnung().isBlank()) {
throw new ValidierungsException("Bezeichnung",
"Das Pflichtfeld 'Bezeichnung' fehlt.");
}
BigDecimal preis = produkt.getEinzelpreisNetto();
if (preis == null) {
throw new ValidierungsException("Einzelpreis",
"Das Pflichtfeld 'Einzelpreis (netto)' fehlt.");
}
if (preis.compareTo(BigDecimal.ZERO) < 0) {
throw new ValidierungsException("Einzelpreis",
"Der 'Einzelpreis (netto)' muss größer oder gleich 0,00 sein.");
}
BigDecimal steuersatz = produkt.getSteuersatz();
if (steuersatz == null) {
throw new ValidierungsException("Steuersatz",
"Das Pflichtfeld 'Steuersatz' fehlt.");
}
boolean zulaessig = ZULAESSIGE_STEUERSAETZE.stream()
.anyMatch(s -> s.compareTo(steuersatz) == 0);
if (!zulaessig) {
throw new ValidierungsException("Steuersatz",
"Unzulässiger 'Steuersatz' " + steuersatz + "; zulässig sind 0.00, 0.07 und 0.19.");
}
}
}

View File

@ -0,0 +1,10 @@
package de.team1.faktura.produkte;
/**
* Vergabe eindeutiger, fortlaufender Produktnummern (B-F-02).
*/
public interface ProduktnummernGenerator {
/** Liefert die nächste fortlaufende Produktnummer, z. B. {@code "P-000042"}. */
String naechsteNummer();
}

View File

@ -0,0 +1,241 @@
package de.team1.faktura.dokumente;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Modultestplan Gruppe A (Pflichtenheft A, Kapitel 10): TC-01 bis TC-13.
* Die Schnittstellen der Gruppen B und C werden durch Stubs ersetzt,
* der PDF-Export durch einen No-Op-Stub.
*/
class DokumentzyklusTest {
private static final String KUNDE_NR = "K-000001";
private static final String PRODUKT_NR = "P-000001";
@TempDir
Path tempDir;
private JsonDokumentRepository repository;
private EinfacherBelegnummernGenerator nummernGenerator;
private Map<String, Produkt> produkte;
private StandardDokumentService service;
@BeforeEach
void setUp() {
repository = new JsonDokumentRepository(tempDir.resolve("dokumente.json"));
nummernGenerator = new EinfacherBelegnummernGenerator();
Kunde kunde = new Kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim");
kunde.setKundennummer(KUNDE_NR);
KundenService kundenStub = new KundenService() {
@Override
public Kunde findeKunde(String kundennummer) {
return KUNDE_NR.equals(kundennummer) ? kunde : null;
}
@Override
public List<Kunde> suche(String suchbegriff) {
return List.of(kunde);
}
};
produkte = new HashMap<>();
produkte.put(PRODUKT_NR, produkt(PRODUKT_NR, "Beratungsstunde", "50.00", "0.19"));
ProduktService produktStub = new ProduktService() {
@Override
public Produkt findeProdukt(String produktnummer) {
return produkte.get(produktnummer);
}
@Override
public List<Produkt> suche(String suchbegriff) {
return List.copyOf(produkte.values());
}
};
PdfExporter pdfStub = (dokument, ziel) -> { };
service = new StandardDokumentService(repository, nummernGenerator,
kundenStub, produktStub, pdfStub);
}
private static Produkt produkt(String nummer, String bezeichnung, String preis, String steuersatz) {
Produkt produkt = new Produkt(bezeichnung, new BigDecimal(preis), new BigDecimal(steuersatz));
produkt.setProduktnummer(nummer);
return produkt;
}
@Test
@DisplayName("TC-01: Position 100.00 EUR @ 0.19 -> Steuer 19.00, Brutto 119.00")
void tc01SteuerUndBruttoEinerPosition() {
Dokumentposition position = new Dokumentposition(
PRODUKT_NR, "Test", 1, new BigDecimal("100.00"), new BigDecimal("0.19"));
assertEquals(new BigDecimal("19.00"), position.getSteuerbetrag());
assertEquals(new BigDecimal("119.00"), position.getPositionssummeBrutto());
}
@Test
@DisplayName("TC-02: Einzelpreis 50.00 EUR, Menge 3 -> Positionssumme 150.00")
void tc02Positionssumme() {
Dokumentposition position = new Dokumentposition(
PRODUKT_NR, "Test", 3, new BigDecimal("50.00"), new BigDecimal("0.19"));
assertEquals(new BigDecimal("150.00"), position.getPositionssummeNetto());
}
@Test
@DisplayName("TC-03: Beleg mit 150.00 @ 0.19 und 50.00 @ 0.07 -> 200.00 / 32.00 / 232.00")
void tc03BelegSummen() {
Rechnung rechnung = new Rechnung();
rechnung.setBelegnummer("R-2026-000001");
rechnung.setzePositionen(List.of(
new Dokumentposition("P-1", "A", 1, new BigDecimal("150.00"), new BigDecimal("0.19")),
new Dokumentposition("P-2", "B", 1, new BigDecimal("50.00"), new BigDecimal("0.07"))));
assertEquals(new BigDecimal("200.00"), rechnung.getSummeNetto());
assertEquals(new BigDecimal("32.00"), rechnung.getSummeSteuer());
assertEquals(new BigDecimal("232.00"), rechnung.getSummeBrutto());
}
@Test
@DisplayName("TC-04: letzte Rechnungsnummer R-2026-000123 -> naechste R-2026-000124 (lückenlos)")
void tc04LueckenloseRechnungsnummer() {
repository.speichere(TestBelege.rechnung("R-2026-000123", DokumentStatus.OFFEN));
EinfacherBelegnummernGenerator generator =
EinfacherBelegnummernGenerator.ausRepository(repository);
assertEquals("R-2026-000124", generator.naechsteNummer(Belegtyp.RECHNUNG, 2026));
}
@Test
@DisplayName("TC-05: Zähler 7, Jahr 2026 -> R-2026-000007 (führende Nullen, String)")
void tc05NummernFormat() {
nummernGenerator.setzeZaehler(Belegtyp.RECHNUNG, 2026, 7);
assertEquals("R-2026-000007", nummernGenerator.naechsteNummer(Belegtyp.RECHNUNG, 2026));
}
@Test
@DisplayName("TC-06: kein Zahlungsziel -> Standard +14 Tage (GR-06)")
void tc06StandardZahlungsziel() {
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
List.of(new Positionsangabe(PRODUKT_NR, 1)),
LocalDate.of(2026, 6, 9), null);
assertEquals(LocalDate.of(2026, 6, 23), rechnung.getZahlungsziel());
}
@Test
@DisplayName("TC-07: abweichendes Zahlungsziel wird übernommen")
void tc07AbweichendesZahlungsziel() {
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
List.of(new Positionsangabe(PRODUKT_NR, 1)),
LocalDate.of(2026, 6, 9), LocalDate.of(2026, 7, 31));
assertEquals(LocalDate.of(2026, 7, 31), rechnung.getZahlungsziel());
}
@Test
@DisplayName("TC-08: Änderung einer versendeten Rechnung wirft IllegalStateException (GR-02)")
void tc08UnveraenderlichkeitVersendet() {
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.of(2026, 6, 9), null);
service.versende(rechnung.getBelegnummer());
Rechnung versendet = (Rechnung) repository.findeNachNummer(rechnung.getBelegnummer());
assertThrows(IllegalStateException.class, () -> versendet.setzePositionen(List.of(
new Dokumentposition("P-9", "Neu", 1, new BigDecimal("1.00"), new BigDecimal("0.19")))));
}
@Test
@DisplayName("TC-09: Storno einer offenen Rechnung -> STORNIERT, nicht mehr offen, storniertAm gesetzt")
void tc09Storno() {
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.of(2026, 6, 9), null);
service.storniere(rechnung.getBelegnummer());
Rechnung storniert = (Rechnung) repository.findeNachNummer(rechnung.getBelegnummer());
assertEquals(DokumentStatus.STORNIERT, storniert.getStatus());
assertTrue(service.offeneRechnungen().stream()
.noneMatch(r -> r.getBelegnummer().equals(rechnung.getBelegnummer())));
assertNotNull(storniert.getStorniertAm());
}
@Test
@DisplayName("TC-10: AB aus Angebot übernimmt Kunde, Positionen, Mengen und Rückreferenz (GR-05)")
void tc10FolgebelegAusAngebot() {
produkte.put("P-000002", produkt("P-000002", "Zweitprodukt", "10.00", "0.07"));
Angebot angebot = service.erstelleAngebot(KUNDE_NR, List.of(
new Positionsangabe(PRODUKT_NR, 2),
new Positionsangabe("P-000002", 5)), null);
Dokument folgebeleg = service.erzeugeFolgebeleg(angebot.getBelegnummer());
assertTrue(folgebeleg instanceof Auftragsbestaetigung);
assertEquals(angebot.getBelegnummer(), folgebeleg.getVorgaengerNr());
assertEquals(angebot.getKundenReferenz(), folgebeleg.getKundenReferenz());
assertEquals(2, folgebeleg.getPositionen().size());
assertEquals(2, folgebeleg.getPositionen().get(0).getMenge());
assertEquals(5, folgebeleg.getPositionen().get(1).getMenge());
}
@Test
@DisplayName("TC-11: Produktpreisänderung lässt bestehende Rechnung unverändert (Snapshot, GR-03)")
void tc11Snapshot() {
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.of(2026, 6, 9), null);
produkte.put(PRODUKT_NR, produkt(PRODUKT_NR, "Beratungsstunde", "80.00", "0.19"));
Rechnung gelesen = (Rechnung) repository.findeNachNummer(rechnung.getBelegnummer());
assertEquals(new BigDecimal("50.00"), gelesen.getPositionen().get(0).getEinzelpreisNetto());
}
@Test
@DisplayName("TC-12: fehlender Kunde bzw. fehlende Position -> Validierungsfehler benennt Pflichtfeld")
void tc12PflichtfeldValidierung() {
ValidierungsException ohneKunde = assertThrows(ValidierungsException.class,
() -> service.erstelleRechnung(null,
List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.now(), null));
assertEquals("Kunde", ohneKunde.getFeldname());
ValidierungsException ohnePosition = assertThrows(ValidierungsException.class,
() -> service.erstelleRechnung(KUNDE_NR, List.of(), LocalDate.now(), null));
assertEquals("Position", ohnePosition.getFeldname());
}
@Test
@DisplayName("TC-13: vollständige Rechnung mit allen Pflichtangaben gemäß § 14 UStG")
void tc13VollstaendigeRechnung() {
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
List.of(new Positionsangabe(PRODUKT_NR, 2)), LocalDate.of(2026, 6, 9), null);
Rechnung gespeichert = (Rechnung) repository.findeNachNummer(rechnung.getBelegnummer());
assertNotNull(gespeichert);
assertTrue(gespeichert.getBelegnummer().startsWith("R-2026-"));
assertEquals(LocalDate.of(2026, 6, 9), gespeichert.getDatum());
assertEquals(LocalDate.of(2026, 6, 9), gespeichert.getLeistungsdatum());
assertEquals("Muster GmbH", gespeichert.getKundeName());
assertEquals("Hauptstr. 1, 68163 Mannheim", gespeichert.getKundeAnschrift());
assertEquals(1, gespeichert.getPositionen().size());
assertEquals(new BigDecimal("0.19"), gespeichert.getPositionen().get(0).getSteuersatz());
assertEquals(new BigDecimal("100.00"), gespeichert.getSummeNetto());
assertEquals(new BigDecimal("19.00"), gespeichert.getSummeSteuer());
assertEquals(new BigDecimal("119.00"), gespeichert.getSummeBrutto());
assertNotNull(gespeichert.getZahlungsziel());
assertFalse(service.offeneRechnungen().isEmpty());
}
}

View File

@ -0,0 +1,31 @@
package de.team1.faktura.dokumente;
import java.math.BigDecimal;
import java.util.List;
/**
* Testhelfer: erzeugt Belege in definierten Status für die Modultests
* (auch der Gruppe D), da der Statuswechsel im Produktivcode bewusst nur
* über die Fachlogik möglich ist.
*/
public final class TestBelege {
private TestBelege() {
}
public static Rechnung rechnung(String belegnummer, DokumentStatus status) {
Rechnung rechnung = new Rechnung();
rechnung.setBelegnummer(belegnummer);
rechnung.setzePositionen(List.of(new Dokumentposition(
"P-000001", "Testprodukt", 1, new BigDecimal("100.00"), new BigDecimal("0.19"))));
rechnung.setzeStatus(status);
return rechnung;
}
public static Angebot angebot(String belegnummer, DokumentStatus status) {
Angebot angebot = new Angebot();
angebot.setBelegnummer(belegnummer);
angebot.setzeStatus(status);
return angebot;
}
}

View File

@ -0,0 +1,334 @@
package de.team1.faktura.gui;
import de.team1.faktura.dokumente.Angebot;
import de.team1.faktura.dokumente.Auftragsbestaetigung;
import de.team1.faktura.dokumente.Dokument;
import de.team1.faktura.dokumente.DokumentService;
import de.team1.faktura.dokumente.DokumentStatus;
import de.team1.faktura.dokumente.Lieferschein;
import de.team1.faktura.dokumente.Positionsangabe;
import de.team1.faktura.dokumente.Rechnung;
import de.team1.faktura.dokumente.Summen;
import de.team1.faktura.dokumente.TestBelege;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Modultestplan Gruppe D (Pflichtenheft D, Kapitel 10): TC-01 bis TC-14.
* Getestet wird die GUI-freie Controller- und Modell-Schicht; die
* Service-Schnittstellen der Gruppen A-C werden durch Stubs ersetzt.
*/
class OberflaechenControllerTest {
private DokumentServiceStub dokumentService;
private KundenService kundenService;
private ProduktService produktService;
private RechnungsWizardController wizard;
@BeforeEach
void setUp() {
dokumentService = new DokumentServiceStub();
Kunde kunde = new Kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim");
kunde.setKundennummer("K-000017");
kundenService = new KundenService() {
@Override
public Kunde findeKunde(String kundennummer) {
return "K-000017".equals(kundennummer) ? kunde : null;
}
@Override
public List<Kunde> suche(String suchbegriff) {
return kunde.getName().toLowerCase().contains(suchbegriff.toLowerCase())
? List.of(kunde) : List.of();
}
};
Produkt produkt = new Produkt("Beratungsstunde", new BigDecimal("80.00"), new BigDecimal("0.19"));
produkt.setProduktnummer("P-000042");
produktService = new ProduktService() {
@Override
public Produkt findeProdukt(String produktnummer) {
return "P-000042".equals(produktnummer) ? produkt : null;
}
@Override
public List<Produkt> suche(String suchbegriff) {
return List.of(produkt);
}
};
wizard = new RechnungsWizardController(dokumentService, kundenService, produktService);
}
private void fuelleGueltigesModell() {
wizard.getModel().setKundenNr("K-000017");
wizard.getModel().fuegePositionHinzu(new PositionsEingabe("P-000042", 2));
wizard.getModel().setRechnungsdatum(LocalDate.of(2026, 6, 10));
}
@Test
@DisplayName("TC-01: neuer Wizard startet mit Schritt KUNDE_WAEHLEN")
void tc01ErsterSchritt() {
assertEquals(WizardSchritt.KUNDE_WAEHLEN, wizard.getModel().getAktuellerSchritt());
}
@Test
@DisplayName("TC-02: viermal weiter() mit gültigen Eingaben durchläuft alle Schritte")
void tc02Schrittfolge() {
fuelleGueltigesModell();
assertTrue(wizard.weiter());
assertEquals(WizardSchritt.POSITIONEN_ERFASSEN, wizard.getModel().getAktuellerSchritt());
assertTrue(wizard.weiter());
assertEquals(WizardSchritt.DATEN_BESTAETIGEN, wizard.getModel().getAktuellerSchritt());
assertTrue(wizard.weiter());
assertEquals(WizardSchritt.ZUSAMMENFASSUNG, wizard.getModel().getAktuellerSchritt());
assertTrue(wizard.weiter());
assertEquals(WizardSchritt.SPEICHERN, wizard.getModel().getAktuellerSchritt());
}
@Test
@DisplayName("TC-03: ohne Kunden wird der Wechsel verhindert; Meldung benennt 'Kunde' (F-10)")
void tc03KeinKunde() {
assertFalse(wizard.weiter());
assertEquals(WizardSchritt.KUNDE_WAEHLEN, wizard.getModel().getAktuellerSchritt());
assertEquals(MeldungsTyp.FEHLER, wizard.getLetzteMeldung().typ());
assertEquals("Kunde", wizard.getLetzteMeldung().feldname());
}
@Test
@DisplayName("TC-04: leere Positionsliste verhindert den Wechsel; Meldung benennt 'Position'")
void tc04KeinePosition() {
wizard.getModel().setKundenNr("K-000017");
assertTrue(wizard.weiter());
assertFalse(wizard.weiter());
assertEquals("Position", wizard.getLetzteMeldung().feldname());
}
@Test
@DisplayName("TC-05: Position mit Menge 0 verhindert den Wechsel; Meldung benennt 'Menge'")
void tc05MengeNull() {
wizard.getModel().setKundenNr("K-000017");
wizard.getModel().fuegePositionHinzu(new PositionsEingabe("P-000042", 0));
assertTrue(wizard.weiter());
assertFalse(wizard.weiter());
assertEquals("Menge", wizard.getLetzteMeldung().feldname());
}
@Test
@DisplayName("TC-06: zurueck() bis Schritt 1 erhält Kunde und Positionen (F-11)")
void tc06ZurueckOhneDatenverlust() {
fuelleGueltigesModell();
wizard.weiter();
wizard.weiter();
assertEquals(WizardSchritt.DATEN_BESTAETIGEN, wizard.getModel().getAktuellerSchritt());
wizard.zurueck();
wizard.zurueck();
assertEquals(WizardSchritt.KUNDE_WAEHLEN, wizard.getModel().getAktuellerSchritt());
assertEquals("K-000017", wizard.getModel().getKundenNr());
assertEquals(1, wizard.getModel().getPositionen().size());
assertEquals(2, wizard.getModel().getPositionen().get(0).menge());
}
@Test
@DisplayName("TC-07: Zusammenfassung enthält Kunde, Positionen, Mengen, Summen, Datum, Zahlungsziel (F-12)")
void tc07Zusammenfassung() {
fuelleGueltigesModell();
dokumentService.summen = new Summen(
new BigDecimal("200.00"), new BigDecimal("38.00"), new BigDecimal("238.00"));
String text = wizard.erzeugeZusammenfassung();
assertTrue(text.contains("Muster GmbH"));
assertTrue(text.contains("2 x Beratungsstunde"));
assertTrue(text.contains("200.00"));
assertTrue(text.contains("38.00"));
assertTrue(text.contains("238.00"));
assertTrue(text.contains("10.06.2026"));
assertTrue(text.contains("Zahlungsziel"));
}
@Test
@DisplayName("TC-08: speichern() löst genau einen Aufruf aus; Erfolgsmeldung nennt Rechnungsnummer (F-13)")
void tc08GenauEinSpeicheraufruf() {
fuelleGueltigesModell();
Meldung meldung = wizard.speichern();
assertEquals(1, dokumentService.erstelleRechnungAufrufe);
assertEquals(MeldungsTyp.ERFOLG, meldung.typ());
assertTrue(meldung.text().contains("R-2026-000124"));
}
@Test
@DisplayName("TC-09: Validierungsfehler der Fachkomponente wird als Fehlermeldung dargestellt (F-05/F-16)")
void tc09SpeichernFehlerfall() {
fuelleGueltigesModell();
dokumentService.erstelleRechnungFehler =
new ValidierungsException("Rechnungsdatum", "Das Pflichtfeld 'Rechnungsdatum' fehlt.");
Meldung meldung = wizard.speichern();
assertEquals(MeldungsTyp.FEHLER, meldung.typ());
assertEquals("Rechnungsdatum", meldung.feldname());
}
@Test
@DisplayName("TC-10: Stornieren ist nur bei Rechnungen im Status OFFEN aktiviert (F-14)")
void tc10StornierenNurOffen() {
DokumentListenController controller = new DokumentListenController(dokumentService);
Rechnung offen = TestBelege.rechnung("R-2026-000001", DokumentStatus.OFFEN);
Rechnung versendet = TestBelege.rechnung("R-2026-000002", DokumentStatus.VERSENDET);
Rechnung storniert = TestBelege.rechnung("R-2026-000003", DokumentStatus.STORNIERT);
assertTrue(controller.aktionenFuer(offen).stornierbar());
assertFalse(controller.aktionenFuer(versendet).stornierbar());
assertFalse(controller.aktionenFuer(storniert).stornierbar());
}
@Test
@DisplayName("TC-11: ohne Bestätigung kein Service-Aufruf; mit Bestätigung genau einer (F-15)")
void tc11StornoNurNachBestaetigung() {
DokumentListenController controller = new DokumentListenController(dokumentService);
assertNull(controller.storniere("R-2026-000124", false));
assertEquals(0, dokumentService.storniereAufrufe);
controller.storniere("R-2026-000124", true);
assertEquals(1, dokumentService.storniereAufrufe);
assertEquals("R-2026-000124", dokumentService.letzteStornierteNummer);
}
@Test
@DisplayName("TC-12: versendeter Beleg: Änderungsaktionen deaktiviert, PDF-Export aktiviert (F-08)")
void tc12VersendeterBeleg() {
DokumentListenController controller = new DokumentListenController(dokumentService);
Rechnung versendet = TestBelege.rechnung("R-2026-000002", DokumentStatus.VERSENDET);
BelegAktionen aktionen = controller.aktionenFuer(versendet);
assertFalse(aktionen.aenderbar());
assertTrue(aktionen.pdfExport());
}
@Test
@DisplayName("TC-13: Statusfilter OFFEN liefert genau die offenen Belege (F-06)")
void tc13Statusfilter() {
dokumentService.dokumente.add(TestBelege.rechnung("R-2026-000001", DokumentStatus.OFFEN));
dokumentService.dokumente.add(TestBelege.rechnung("R-2026-000002", DokumentStatus.OFFEN));
dokumentService.dokumente.add(TestBelege.rechnung("R-2026-000003", DokumentStatus.STORNIERT));
DokumentListenController controller = new DokumentListenController(dokumentService);
List<Dokument> offene = controller.gefiltert(DokumentStatus.OFFEN);
assertEquals(2, offene.size());
assertTrue(offene.stream().allMatch(d -> d.getStatus() == DokumentStatus.OFFEN));
}
@Test
@DisplayName("TC-14: Stammdaten-Suche delegiert an KundenService und liefert den Treffer (F-03)")
void tc14StammdatenSuche() {
StammdatenController controller = new StammdatenController(kundenService, produktService);
List<Kunde> treffer = controller.sucheKunden("Muster");
assertEquals(1, treffer.size());
assertEquals("K-000017", treffer.get(0).getKundennummer());
}
/** Zähl-Stub des DokumentService (Gruppe A) für die Controller-Tests. */
private static final class DokumentServiceStub implements DokumentService {
int erstelleRechnungAufrufe;
int storniereAufrufe;
String letzteStornierteNummer;
ValidierungsException erstelleRechnungFehler;
Summen summen = new Summen(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
final List<Dokument> dokumente = new ArrayList<>();
@Override
public Rechnung erstelleRechnung(String kundenNr, List<Positionsangabe> positionen,
LocalDate rechnungsdatum, LocalDate zahlungsziel) {
erstelleRechnungAufrufe++;
if (erstelleRechnungFehler != null) {
throw erstelleRechnungFehler;
}
Rechnung rechnung = new Rechnung();
rechnung.setBelegnummer("R-2026-000124");
return rechnung;
}
@Override
public void storniere(String rechnungsnummer) {
storniereAufrufe++;
letzteStornierteNummer = rechnungsnummer;
}
@Override
public Summen berechneSummen(List<Positionsangabe> positionen) {
return summen;
}
@Override
public List<Dokument> alleDokumente() {
return dokumente;
}
@Override
public Angebot erstelleAngebot(String kundenNr, List<Positionsangabe> positionen,
LocalDate gueltigBis) {
return new Angebot();
}
@Override
public Auftragsbestaetigung erstelleAuftragsbestaetigung(String kundenNr,
List<Positionsangabe> positionen) {
return new Auftragsbestaetigung();
}
@Override
public Lieferschein erstelleLieferschein(String kundenNr, List<Positionsangabe> positionen,
LocalDate lieferdatum) {
return new Lieferschein();
}
@Override
public Dokument erzeugeFolgebeleg(String belegnummer) {
return null;
}
@Override
public void versende(String belegnummer) {
}
@Override
public List<Rechnung> offeneRechnungen() {
return List.of();
}
@Override
public void exportierePdf(String belegnummer, Path zielDatei) {
}
}
}

View File

@ -0,0 +1,201 @@
package de.team1.faktura.kunden;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Modultestplan Gruppe C (Pflichtenheft C, Kapitel 10): TC-01 bis TC-14.
* Die Schnittstelle {@code KundenReferenzPruefung} (Gruppe A) wird durch
* einen Stub ersetzt.
*/
class KundenVerwaltungTest {
@TempDir
Path tempDir;
private JsonKundenRepository repository;
private final Map<String, Integer> verknuepfteDokumente = new HashMap<>();
@BeforeEach
void setUp() {
repository = new JsonKundenRepository(tempDir.resolve("kunden.json"));
}
private KundenVerwaltungsService service(KundennummernGenerator generator) {
return new KundenVerwaltungsService(repository, generator,
kundennummer -> verknuepfteDokumente.getOrDefault(kundennummer, 0));
}
private KundenVerwaltungsService serviceAusRepository() {
return service(EinfacherKundennummernGenerator.ausRepository(repository));
}
private static Kunde kunde(String name, String strasse, String plz, String ort) {
return new Kunde(name, strasse, plz, ort);
}
private Kunde lege(String nummer, String name) {
Kunde kunde = kunde(name, "Hauptstr. 1", "68163", "Mannheim");
kunde.setKundennummer(nummer);
return repository.speichere(kunde);
}
@Test
@DisplayName("TC-01: höchste Nummer K-000016 -> neuer Kunde erhält K-000017")
void tc01NummernVergabe() {
lege("K-000016", "Bestehender Kunde");
Kunde gespeichert = serviceAusRepository()
.legeAn(kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim"));
assertEquals("K-000017", gespeichert.getKundennummer());
assertNotNull(repository.findeNachNummer("K-000017"));
}
@Test
@DisplayName("TC-02: Zähler 7 -> K-000007 (führende Nullen, String)")
void tc02NummernFormat() {
assertEquals("K-000007", new EinfacherKundennummernGenerator(7).naechsteNummer());
}
@Test
@DisplayName("TC-03: fehlender Ort wird abgelehnt und benannt (Q-09)")
void tc03FehlenderOrt() {
ValidierungsException fehler = assertThrows(ValidierungsException.class,
() -> serviceAusRepository().legeAn(kunde("Muster GmbH", "Hauptstr. 1", "68163", null)));
assertEquals("Ort", fehler.getFeldname());
}
@Test
@DisplayName("TC-04: leerer Name wird abgelehnt und benannt")
void tc04LeererName() {
ValidierungsException fehler = assertThrows(ValidierungsException.class,
() -> serviceAusRepository().legeAn(kunde("", "Hauptstr. 1", "68163", "Mannheim")));
assertEquals("Name", fehler.getFeldname());
}
@Test
@DisplayName("TC-05: ungültige E-Mail 'max.mustermann' wird abgelehnt (C-F-04)")
void tc05UngueltigeEMail() {
Kunde kunde = kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim");
kunde.setEMail("max.mustermann");
ValidierungsException fehler = assertThrows(ValidierungsException.class,
() -> serviceAusRepository().legeAn(kunde));
assertEquals("E-Mail", fehler.getFeldname());
}
@Test
@DisplayName("TC-06: gültige E-Mail 'max@beispiel.de' wird gespeichert")
void tc06GueltigeEMail() {
Kunde kunde = kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim");
kunde.setEMail("max@beispiel.de");
Kunde gespeichert = serviceAusRepository().legeAn(kunde);
assertEquals("max@beispiel.de",
repository.findeNachNummer(gespeichert.getKundennummer()).getEMail());
}
@Test
@DisplayName("TC-07: Ortsänderung Mannheim -> Heidelberg wird gespeichert")
void tc07OrtAendern() {
KundenVerwaltungsService service = serviceAusRepository();
Kunde kunde = service.legeAn(kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim"));
kunde.setOrt("Heidelberg");
service.aendere(kunde);
assertEquals("Heidelberg", repository.findeNachNummer(kunde.getKundennummer()).getOrt());
}
@Test
@DisplayName("TC-08: Änderungsversuch der Kundennummer wirft IllegalArgumentException")
void tc08KundennummerUnveraenderlich() {
Kunde kunde = serviceAusRepository().legeAn(kunde("Muster GmbH", "Hauptstr. 1", "68163", "Mannheim"));
assertThrows(IllegalArgumentException.class,
() -> kunde.setKundennummer("K-999999"));
}
@Test
@DisplayName("TC-09: unverknüpfter Kunde wird nach Bestätigung gelöscht")
void tc09LoeschenUnverknuepft() {
lege("K-000011", "Unverknüpft");
serviceAusRepository().loescheKunde("K-000011");
assertTrue(repository.alleSortiertNachName().stream()
.noneMatch(k -> k.getKundennummer().equals("K-000011")));
}
@Test
@DisplayName("TC-10: Kunde mit 3 verknüpften Dokumenten wird nicht gelöscht; Hinweis nennt Anzahl (GR-04)")
void tc10Loeschsperre() {
lege("K-000010", "Referenziert");
verknuepfteDokumente.put("K-000010", 3);
LoeschAbgelehntException fehler = assertThrows(LoeschAbgelehntException.class,
() -> serviceAusRepository().loescheKunde("K-000010"));
assertNotNull(repository.findeNachNummer("K-000010"));
assertTrue(fehler.getMessage().contains("3"));
}
@Test
@DisplayName("TC-11: Auflistung sortiert nach Name")
void tc11Sortierung() {
lege("K-000001", "Zimmer");
lege("K-000002", "Albrecht");
lege("K-000003", "Maier");
List<String> namen = repository.alleSortiertNachName().stream()
.map(Kunde::getName)
.toList();
assertEquals(List.of("Albrecht", "Maier", "Zimmer"), namen);
}
@Test
@DisplayName("TC-12: Suche ist case-insensitive und findet Teilstrings")
void tc12SucheName() {
lege("K-000001", "Muster GmbH");
List<Kunde> treffer = serviceAusRepository().suche("MUSTER");
assertTrue(treffer.stream().anyMatch(k -> k.getName().equals("Muster GmbH")));
}
@Test
@DisplayName("TC-13: Suche nach Kundennummer trifft; findeKunde liefert null für Unbekannte (C-F-14)")
void tc13SucheNummerUndFindeKunde() {
lege("K-000017", "Muster GmbH");
KundenVerwaltungsService service = serviceAusRepository();
List<Kunde> treffer = service.suche("K-000017");
assertTrue(treffer.stream().anyMatch(k -> k.getKundennummer().equals("K-000017")));
assertNull(service.findeKunde("K-999999"));
}
@Test
@DisplayName("TC-14: CSV-Export mit Kopfzeile, Semikolon-getrennt, UTF-8 (C-F-15)")
void tc14CsvExport() throws Exception {
lege("K-000001", "Albrecht");
lege("K-000002", "Maier");
lege("K-000003", "Zimmer");
Path ziel = tempDir.resolve("kunden.csv");
new KundenCsvExport(repository).exportiereCsv(ziel);
List<String> zeilen = Files.readAllLines(ziel, StandardCharsets.UTF_8);
assertEquals(4, zeilen.size());
assertEquals("kundennummer;name;strasse;plz;ort;eMail;telefon;ustIdNr", zeilen.get(0));
assertTrue(zeilen.get(1).startsWith("K-000001;Albrecht;"));
}
}

View File

@ -0,0 +1,197 @@
package de.team1.faktura.produkte;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Modultestplan Gruppe B (Pflichtenheft B, Kapitel 10): TC-01 bis TC-14.
* Die Schnittstelle {@code ProduktReferenzPruefung} (Gruppe A) wird durch
* einen Stub ersetzt.
*/
class ProduktVerwaltungTest {
@TempDir
Path tempDir;
private JsonProduktRepository repository;
private final Set<String> referenzierteProdukte = new HashSet<>();
@BeforeEach
void setUp() {
repository = new JsonProduktRepository(tempDir.resolve("produkte.json"));
}
private ProduktVerwaltungsService service(ProduktnummernGenerator generator) {
return new ProduktVerwaltungsService(repository, generator, referenzierteProdukte::contains);
}
private ProduktVerwaltungsService serviceAusRepository() {
return service(EinfacherProduktnummernGenerator.ausRepository(repository));
}
private static Produkt produkt(String bezeichnung, String preis, String steuersatz) {
return new Produkt(bezeichnung, new BigDecimal(preis), new BigDecimal(steuersatz));
}
private Produkt lege(String nummer, String bezeichnung) {
Produkt produkt = produkt(bezeichnung, "10.00", "0.19");
produkt.setProduktnummer(nummer);
return repository.speichere(produkt);
}
@Test
@DisplayName("TC-01: höchste Nummer P-000041 -> neues Produkt erhält P-000042")
void tc01NummernVergabe() {
lege("P-000041", "Bestehendes Produkt");
Produkt gespeichert = serviceAusRepository()
.legeAn(produkt("Beratungsstunde", "80.00", "0.19"));
assertEquals("P-000042", gespeichert.getProduktnummer());
assertNotNull(repository.findeNachNummer("P-000042"));
}
@Test
@DisplayName("TC-02: Zähler 7 -> P-000007 (führende Nullen, String)")
void tc02NummernFormat() {
assertEquals("P-000007", new EinfacherProduktnummernGenerator(7).naechsteNummer());
}
@Test
@DisplayName("TC-03: negativer Einzelpreis wird abgelehnt")
void tc03NegativerPreis() {
ValidierungsException fehler = assertThrows(ValidierungsException.class,
() -> serviceAusRepository().legeAn(produkt("Test", "-1.00", "0.19")));
assertEquals("Einzelpreis", fehler.getFeldname());
}
@Test
@DisplayName("TC-04: unzulässiger Steuersatz 0.15 wird abgelehnt")
void tc04UnzulaessigerSteuersatz() {
ValidierungsException fehler = assertThrows(ValidierungsException.class,
() -> serviceAusRepository().legeAn(produkt("Test", "10.00", "0.15")));
assertEquals("Steuersatz", fehler.getFeldname());
}
@Test
@DisplayName("TC-05: fehlende Bezeichnung wird abgelehnt und benannt (Q-09)")
void tc05FehlendeBezeichnung() {
ValidierungsException fehler = assertThrows(ValidierungsException.class,
() -> serviceAusRepository().legeAn(produkt(null, "10.00", "0.19")));
assertEquals("Bezeichnung", fehler.getFeldname());
}
@Test
@DisplayName("TC-06: Preisänderung 80.00 -> 95.00 wird gespeichert")
void tc06PreisAendern() {
ProduktVerwaltungsService service = serviceAusRepository();
Produkt produkt = service.legeAn(produkt("Beratungsstunde", "80.00", "0.19"));
produkt.setEinzelpreisNetto(new BigDecimal("95.00"));
service.aendere(produkt);
assertEquals(new BigDecimal("95.00"),
repository.findeNachNummer(produkt.getProduktnummer()).getEinzelpreisNetto());
}
@Test
@DisplayName("TC-07: Änderungsversuch der Produktnummer wirft IllegalArgumentException")
void tc07ProduktnummerUnveraenderlich() {
Produkt produkt = serviceAusRepository().legeAn(produkt("Beratungsstunde", "80.00", "0.19"));
assertThrows(IllegalArgumentException.class,
() -> produkt.setProduktnummer("P-999999"));
}
@Test
@DisplayName("TC-08: unverknüpftes Produkt wird nach Bestätigung gelöscht")
void tc08LoeschenUnverknuepft() {
lege("P-000011", "Unverknüpft");
serviceAusRepository().loescheProdukt("P-000011");
assertTrue(repository.alleSortiertNachBezeichnung().stream()
.noneMatch(p -> p.getProduktnummer().equals("P-000011")));
}
@Test
@DisplayName("TC-09: referenziertes Produkt wird nicht gelöscht (Löschsperre)")
void tc09Loeschsperre() {
lege("P-000010", "Referenziert");
referenzierteProdukte.add("P-000010");
LoeschAbgelehntException fehler = assertThrows(LoeschAbgelehntException.class,
() -> serviceAusRepository().loescheProdukt("P-000010"));
assertNotNull(repository.findeNachNummer("P-000010"));
assertTrue(fehler.getMessage().contains("P-000010"));
}
@Test
@DisplayName("TC-10: Auflistung sortiert nach Bezeichnung")
void tc10Sortierung() {
lege("P-000001", "Zaun");
lege("P-000002", "Anker");
lege("P-000003", "Mast");
List<String> bezeichnungen = repository.alleSortiertNachBezeichnung().stream()
.map(Produkt::getBezeichnung)
.toList();
assertEquals(List.of("Anker", "Mast", "Zaun"), bezeichnungen);
}
@Test
@DisplayName("TC-11: Suche ist case-insensitive und findet Teilstrings")
void tc11SucheBezeichnung() {
lege("P-000001", "Beratungsstunde");
List<Produkt> treffer = serviceAusRepository().suche("BERATUNG");
assertTrue(treffer.stream().anyMatch(p -> p.getBezeichnung().equals("Beratungsstunde")));
}
@Test
@DisplayName("TC-12: Suche nach Produktnummer findet genau das Produkt")
void tc12SucheNummer() {
lege("P-000042", "Beratungsstunde");
lege("P-000043", "Anderes Produkt");
List<Produkt> treffer = serviceAusRepository().suche("P-000042");
assertEquals(1, treffer.size());
assertEquals("P-000042", treffer.get(0).getProduktnummer());
}
@Test
@DisplayName("TC-13: findeProdukt liefert null für unbekannte Nummer (B-F-14)")
void tc13FindeProduktNull() {
assertNull(serviceAusRepository().findeProdukt("P-999999"));
}
@Test
@DisplayName("TC-14: CSV-Export mit Kopfzeile, Semikolon-getrennt, UTF-8 (B-F-15)")
void tc14CsvExport() throws Exception {
lege("P-000001", "Anker");
lege("P-000002", "Mast");
lege("P-000003", "Zaun");
Path ziel = tempDir.resolve("produkte.csv");
new ProduktCsvExport(repository).exportiereCsv(ziel);
List<String> zeilen = Files.readAllLines(ziel, StandardCharsets.UTF_8);
assertEquals(4, zeilen.size());
assertEquals("produktnummer;bezeichnung;beschreibung;einzelpreisNetto;steuersatz;einheit",
zeilen.get(0));
assertTrue(zeilen.get(1).startsWith("P-000001;Anker;"));
}
}