Pflichtenheft + Maven Build
parent
37eee25bd6
commit
a4fc341341
|
|
@ -0,0 +1,10 @@
|
|||
# Maven-Build
|
||||
target/
|
||||
|
||||
# Lokale Laufzeitdaten der Anwendung (IF-01, Q-06)
|
||||
daten/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
|
@ -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-Rundungsfehler
|
||||
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-01–F-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-05–F-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-08–F-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-11–F-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-16–F-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-19–F-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.
|
|
@ -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-Rundungsfehler
|
||||
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-01–F-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-08–F-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-11–F-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-05–BA-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.
|
|
@ -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-01–F-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-05–F-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-08–F-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-11–F-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-01–BA-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.
|
|
@ -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-16–F-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 A–C. 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 A–C 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 A–C 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 A–C 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 *Model–View–Controller*: 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 A–C 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-06–F-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-09–F-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-06–F-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-01–BA-08 | Stammdatenpflege (Bedienzugang) | F-03, F-04, F-05 |
|
||||
| BA-09–BA-12 | Belegerstellung (Bedienzugang) | F-06, F-07 |
|
||||
| GR-02 | Unveränderlichkeit versendeter Dokumente | F-08 (Darstellung) |
|
||||
| PZ-03 | Bedienbarkeit ohne Vorkenntnisse | F-09–F-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-16–F-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 A–C 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-09–F-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 | Model–View–Controller (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.
|
|
@ -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>
|
||||
|
|
@ -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" "$@"
|
||||
|
|
@ -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%
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package de.team1.faktura.gui;
|
||||
|
||||
/**
|
||||
* Art einer Benutzer-Meldung (Gruppe D, Kapitel 6.1).
|
||||
*/
|
||||
public enum MeldungsTyp {
|
||||
ERFOLG,
|
||||
FEHLER
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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])));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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++);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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-08–F-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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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++);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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-08–F-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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue