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