diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf61a71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Maven-Build +target/ + +# Lokale Laufzeitdaten der Anwendung (IF-01, Q-06) +daten/ + +# IDE +.idea/ +.vscode/ +*.iml diff --git a/Pflichtenheft_GruppeA.md b/Pflichtenheft_GruppeA.md new file mode 100644 index 0000000..a078b6a --- /dev/null +++ b/Pflichtenheft_GruppeA.md @@ -0,0 +1,560 @@ +--- +title: "Pflichtenheft" +subtitle: "Desktop-Fakturierungsanwendung — Gruppe A: Prozess / Dokumentenzyklus" +author: + - Team 1 – Gruppe A +version: "1.0" +lang: de-DE +toc: true +toc-depth: 3 +numbersections: false +papersize: a4 +geometry: "margin=3cm" +fontsize: 12pt +linestretch: 1.5 +mainfont: "Times New Roman" +sansfont: "Arial" +monofont: "DejaVu Sans Mono" +header-includes: | + \usepackage{fancyhdr} + \usepackage{lastpage} + \pagestyle{fancy} + \fancyhf{} + \fancyhead[L]{Team 1 – Gruppe A} + \fancyhead[C]{Pflichtenheft} + \fancyhead[R]{Version 1.0} + \fancyfoot[C]{\thepage\ /\ \pageref{LastPage}} + \renewcommand{\headrulewidth}{0.4pt} + \renewcommand{\footrulewidth}{0pt} +--- + +\newpage + ++-------------------------+-------------------------+-------------------------+ +| Autor | Prüfer | Freigebender | ++=========================+=========================+=========================+ +| Strubel, Lucas | Prof. Dr. Marmitt, Gerd | Prof. Dr. Marmitt, Gerd | +| Kaiser, Luca | | | ++-------------------------+-------------------------+-------------------------+ +| Gruppe A (Prozess) | Modulverantwortlicher | Modulverantwortlicher | ++-------------------------+-------------------------+-------------------------+ +| 09.06.2026 | 09.06.2026 | 09.06.2026 | ++-------------------------+-------------------------+-------------------------+ + +**Freigabevermerk:** Dieses Dokument ist nach Prüfung und Freigabe durch den +Modulverantwortlichen verbindliche Spezifikationsgrundlage für die Implementierung +und den Modultest der Komponente *Prozess / Dokumentenzyklus*. + +## Dokumentenhistorie + +| Version | Datum | Autor | Grund der Änderung | +|---------|------------|-----------------------------|---------------------| +| 1.0 | 09.06.2026 | Lucas Strubel, Luca Kaiser | Initiale Erstellung | + +\newpage + +## 1. Einleitung + +### 1.1 Zweck des Dokuments +Dieses Pflichtenheft (System Requirements Specification, SRS) beschreibt aus Sicht des +Auftragnehmers, **wie** die Komponente *Prozess / Dokumentenzyklus* der +Desktop-Fakturierungsanwendung die Anforderungen des Lastenhefts (v1.3) erfüllt. Es +konkretisiert die fachlichen Anforderungen in testbare Systemanforderungen und dient als +direkte Grundlage für Design, Implementierung sowie den Komponenten- bzw. Modultestplan +(Kapitel 10). + +### 1.2 Ziel +Ziel dieses Pflichtenhefts ist die vollständige und testbare Spezifikation der Erzeugung, +Verknüpfung und Statusführung der vier kaufmännischen Belegtypen (Angebot, +Auftragsbestätigung, Lieferschein, Rechnung), der geführten Rechnungserstellung sowie der +Rechnungsstornierung. + +### 1.3 Geltungsbereich +Dieses Dokument gilt für die Komponente **Gruppe A — Prozess / Dokumentenzyklus**. Die +Gesamtanwendung wird arbeitsteilig in vier Komponenten entwickelt; jede Untergruppe pflegt +ein eigenes Pflichtenheft: + +| Gruppe | Komponente | Eigenes Pflichtenheft | +|--------|-------------------------|-----------------------| +| A | Prozess / Dokumentenzyklus | **dieses Dokument** | +| B | Verwaltung von Produkten | separat | +| C | Verwaltung von Kunden | separat | +| D | Programmoberfläche | separat | + +Die Komponente A **nutzt** Kunden (Gruppe C) und Produkte (Gruppe B) lesend über definierte +interne Schnittstellen (Kapitel 6.2) und wird über die Programmoberfläche (Gruppe D) +bedient. Stammdatenpflege (Anlegen/Ändern/Löschen von Kunden und Produkten) ist **nicht** +Gegenstand dieses Dokuments. + +### 1.4 Definitionen und Abkürzungen +Fachbegriffe (Angebot, Auftragsbestätigung, Lieferschein, Rechnung, Dokumentenzyklus, +GoBD, DSGVO, …) sind im Glossar des Lastenhefts (§ 8.1) definiert und gelten unverändert. +Dokumentspezifische Abkürzungen siehe Kapitel 11. + +### 1.5 Referenzen +- Lastenheft „Desktop-Fakturierungsanwendung", Team 1, Version 1.3, 09.06.2026 +- Project Charter, Team 1, Version 1.3, 14.05.2026 +- § 14 UStG — Pflichtangaben einer Rechnung +- GoBD — Grundsätze zur ordnungsmäßigen Führung und Aufbewahrung von Büchern +- DSGVO — EU-Verordnung 2016/679 +- Vorlesungsunterlagen Software Engineering 1 (SoSe 2026), Foliensatz „Lasten- und Pflichtenheft" + +--- + +## 2. Systemüberblick + +### 2.1 Kurzbeschreibung +Die Anwendung ist eine **Einzelplatz-Stand-Alone-Desktop-Anwendung** mit **lokaler +Datenhaltung** (keine Cloud, kein Server). Die Bedienung erfolgt über eine **minimale +grafische Benutzeroberfläche** (Gruppe D), über die die Funktionalität des +Dokumentenzyklus zugänglich gemacht wird. Erzeugte Belege werden lokal als **PDF** +exportiert und können optional gedruckt oder per Standard-E-Mail-Client versendet werden. + +Die Komponente *Prozess / Dokumentenzyklus* stellt die fachliche Kernlogik bereit: +Belegerzeugung, automatische Summen- und Steuerberechnung, Vergabe eindeutiger +Belegnummern, Verknüpfung aufeinanderfolgender Belege, Statusführung und Stornierung. + +### 2.2 Abgrenzung (Was gehört dazu / was nicht) +**Im Umfang dieser Komponente:** + +- Erstellen von Angebot, Auftragsbestätigung, Lieferschein, Rechnung +- Übernahme von Daten aus Vorgängerbelegen (Dokumentenzyklus-Konsistenz) +- Automatische Berechnung von Netto-, Steuer- und Bruttobeträgen (Snapshot-Prinzip) +- Vergabe fortlaufender, lückenloser Belegnummern +- Geführte (schrittweise) Rechnungserstellung +- Statusführung und Stornierung von Rechnungen +- PDF-Export der Belege + +**Nicht im Umfang dieser Komponente:** + +- Anlegen/Ändern/Löschen von Kunden (Gruppe C) und Produkten (Gruppe B) +- Aufbau und Layout der GUI (Gruppe D) +- E-Rechnungsformate (ZUGFeRD/XRechnung), Mahnwesen, Buchhaltung (LH-Nichtziele) + +### 2.3 Grobe Systemfunktionen +Erstellen von Belegen → Berechnen der Summen → Vergeben der Belegnummer → Persistieren → +PDF-Export → Statuswechsel (inkl. Storno). + +### 2.4 UML-Bezug +Ein gemeinsames Use-Case-Diagramm aller Gruppen gibt den Überblick über die Akteure und +Ziele. Die für Gruppe A relevanten Use Cases sind: *Angebot erstellen*, +*Auftragsbestätigung erstellen*, *Lieferschein erstellen*, *Rechnung erstellen (geführt)* +und *Rechnung stornieren*. Die detaillierte logische Architektur dieser Komponente folgt +in Kapitel 7. + +--- + +## 3. Stakeholder und Kontext +Stakeholder und Systemkontext sind im Lastenheft (§ 2, § 3) beschrieben und gelten +unverändert. Für diese Komponente ist der maßgebliche Akteur: + +- **Anwender:in** — natürliche Person (Selbstständige:r, Freiberufler:in, + Kleinstunternehmer:in), die den Dokumentenzyklus eigenverantwortlich durchführt. + +Angrenzende Systeme/Komponenten: lokales Dateisystem (Persistenz, PDF), optional Drucker +und Standard-E-Mail-Client sowie intern die Komponenten Kundenverwaltung (C) und +Produktverwaltung (B). + +--- + +## 4. Funktionale Anforderungen + +Die Anforderungen sind nach Belegtyp gruppiert und mit den Satzschablonen des Foliensatzes +formuliert. Jede Anforderung ist eindeutig, vollständig, widerspruchsfrei und verifizierbar. + +> **Belegnummern (übergreifend, GR-01):** Belegnummern sind **eindeutig** und werden +> **vom System generiert** (nicht durch den Anwender eingegeben). Sie werden als +> `String` geführt, **nicht** als `int`, weil die Nummern ein festes Format mit +> führenden Nullen und Präfix besitzen (z. B. `R-2026-000124`); ein ganzzahliger Typ +> würde führende Nullen verlieren. Pro Belegtyp wird ein eigener, fortlaufender und +> **lückenloser** Zähler auf Basis der höchsten bisher vergebenen Nummer geführt. +> Präfixe: `AN-` (Angebot), `AB-` (Auftragsbestätigung), `LS-` (Lieferschein), +> `R-` (Rechnung). + +### 4.1 Angebot (aus BA-09) + +**F-01:** Das System MUSS es der Anwender:in ERMÖGLICHEN, ein Angebot für einen +existierenden Kunden mit mindestens einer Position aus dem Produktkatalog zu erstellen. + +**F-02:** WENN ein Angebot gespeichert wird, DANN MUSS das System eine eindeutige +Angebotsnummer (Präfix `AN-`), das Erstelldatum und ein Gültigkeitsdatum setzen. + +**F-03:** Das System MUSS für jedes Angebot die Netto-, Steuer- und Bruttosumme automatisch +aus den Positionen berechnen (siehe F-23). + +**F-04:** Das System MUSS es ERMÖGLICHEN, ein gespeichertes Angebot als PDF in das lokale +Dateisystem zu exportieren. + +### 4.2 Auftragsbestätigung (aus BA-10) + +**F-05:** Das System MUSS es ERMÖGLICHEN, eine Auftragsbestätigung zu erstellen, wobei +Kunde, Positionen und Mengen aus einem vorhandenen Angebot übernommen werden können +(siehe F-22). + +**F-06:** WENN eine Auftragsbestätigung gespeichert wird, DANN MUSS das System eine +eindeutige AB-Nummer (Präfix `AB-`) vergeben und — sofern aus einem Angebot erzeugt — eine +Rückreferenz auf das Angebot speichern. + +**F-07:** Das System MUSS es ERMÖGLICHEN, eine Auftragsbestätigung als PDF zu exportieren. + +### 4.3 Lieferschein (aus BA-11) + +**F-08:** Das System MUSS es ERMÖGLICHEN, einen Lieferschein mit Lieferdatum, Positionen +und Liefermengen zu erstellen; Kunde und Positionen können aus einer Auftragsbestätigung +übernommen werden (siehe F-22). + +**F-09:** WENN ein Lieferschein gespeichert wird, DANN MUSS das System eine eindeutige +Lieferscheinnummer (Präfix `LS-`) vergeben und — sofern aus einer Auftragsbestätigung +erzeugt — eine Rückreferenz speichern. + +**F-10:** Das System MUSS es ERMÖGLICHEN, einen Lieferschein als PDF zu exportieren. + +### 4.4 Rechnung (aus BA-12) + +**F-11:** Das System MUSS es ERMÖGLICHEN, eine Rechnung für einen Kunden mit mindestens +einer Position zu erstellen. + +**F-12:** WENN eine Rechnung gespeichert wird, DANN MUSS das System eine fortlaufende, +lückenlose Rechnungsnummer (Präfix `R-`) auf Basis der höchsten bisher vergebenen Nummer +vergeben (GR-01). + +**F-13:** Das System MUSS in jeder Rechnung die Pflichtangaben gemäß § 14 UStG führen: +Rechnungsnummer, Rechnungsdatum, Leistungsdatum, Kundendaten, Positionen mit Einzel- und +Gesamtbeträgen, Steuersatz und Steuerbetrag sowie Netto-, Steuer- und Bruttosumme. + +**F-14:** WENN bei der Rechnungserstellung kein abweichendes Zahlungsziel angegeben ist, +DANN MUSS das System ein Standard-Zahlungsziel von **14 Kalendertagen** ab Rechnungsdatum +setzen (GR-06). + +**F-15:** Das System MUSS es ERMÖGLICHEN, eine Rechnung als PDF zu exportieren. + +### 4.5 Geführte Rechnungserstellung (aus BA-13) + +**F-16:** Das System MUSS es ERMÖGLICHEN, die Rechnungserstellung schrittweise +durchzuführen: (1) Kunde auswählen, (2) mindestens eine Produktposition mit Menge erfassen, +(3) Rechnungsdatum und Zahlungsziel bestätigen, (4) Zusammenfassung prüfen, (5) speichern. + +**F-17:** WENN die Schritteingabe abgeschlossen ist, DANN MUSS das System vor dem Speichern +eine **Zusammenfassung** mit Kunde, allen Positionen, Mengen, Netto-/Steuer-/Bruttosumme, +Rechnungsdatum und Zahlungsziel anzeigen. + +**F-18:** WENN ein Pflichtfeld fehlt (kein Kunde, keine Position, kein Rechnungsdatum), +DANN MUSS das System das Speichern ablehnen und das fehlende Pflichtfeld benennen (GR/Q-09). + +### 4.6 Rechnung stornieren (aus BA-14) + +**F-19:** Das System MUSS es ERMÖGLICHEN, eine gespeicherte Rechnung im Status `OFFEN` zu +stornieren. + +**F-20:** WENN eine Rechnung storniert wird, DANN MUSS das System ihren Status auf +`STORNIERT` setzen, sie nicht mehr in der Liste offener Rechnungen führen und den Vorgang +mit Datum protokollieren. + +**F-21:** WENN eine Rechnung den Status `STORNIERT` hat, DANN MUSS das System jede weitere +inhaltliche Änderung ablehnen. + +### 4.7 Übergreifende Prozessregeln + +**F-22 (Dokumentenzyklus-Konsistenz, GR-05):** WENN ein Beleg aus einem Vorgängerbeleg +erzeugt wird, DANN MUSS das System Kunde, Positionen und Mengen übernehmen und eine +eindeutige Rückreferenz auf den Vorgänger speichern. + +**F-23 (Steuer-/Summenberechnung, GR-03):** WENN eine Position gespeichert wird, DANN MUSS +das System Netto-, Steuer- und Bruttobetrag automatisch berechnen, wobei der zum Zeitpunkt +der Belegerstellung gültige Steuersatz und Einzelpreis des Produkts als unveränderlicher +**Snapshot** in der Position abgelegt werden. + +**F-24 (Unveränderlichkeit, GR-02 / Q-07):** WENN ein Beleg den Status `VERSENDET` hat, +DANN MUSS das System jede inhaltliche Änderung ablehnen; Korrekturen erfolgen ausschließlich +über neue Belege (Storno-/Korrekturrechnung). + +--- + +## 5. Nicht-funktionale Anforderungen + +**NF-PERF-01 (aus Q-03):** Das System MUSS die Erstellung und den PDF-Export eines beliebigen +Belegtyps INNERHALB VON 2 SEKUNDEN abschließen, bei Belegen mit bis zu 50 Positionen und +einem Datenbestand gemäß Q-01 (bis 5.000 Kunden/Produkte). + +**NF-INT-01 (aus Q-07 / GR-02):** Das System MUSS nach dem Status `VERSENDET` einer Rechnung +**jede** inhaltliche Änderung ablehnen; zulässig bleiben ausschließlich Storno- bzw. +Korrekturvorgänge über neue Belege. + +**NF-USE-01 (aus Q-05):** Die geführte Erstellung einer vollständigen Rechnung an einen +bestehenden Kunden MUSS von einer erstmaligen Anwender:in OHNE EXTERNE HILFE IN WENIGER ALS +10 MINUTEN im ersten Versuch abgeschlossen werden können (Nachweis durch Usability-Test mit +mind. 5 Testpersonen). + +**NF-USE-02 (aus Q-09):** Das System MUSS fehlende Pflichtangaben in den Belegformularen so +markieren und benennen, dass mindestens 80 % der Testpersonen die fehlende Eingabe ohne +externe Hilfe im ersten Korrekturversuch ergänzen können. + +--- + +## 6. Daten und Schnittstellen + +Dieses Kapitel ist direkter Input für den Modultestplan (Kapitel 10). Datentypen werden +bereits als Java-Typen angegeben. + +### 6.1 Datenobjekte und Datentypen + +**Designgrundsätze:** + +- **Geldbeträge** werden als `java.math.BigDecimal` mit **Scale 2** und kaufmännischer + Rundung (`RoundingMode.HALF_UP`) geführt — **nicht** als `double` (Gleitkomma-Rundungs­fehler + wären für Beträge unzulässig). +- **Belegnummern** werden als `String` geführt (festes Format mit Präfix und führenden + Nullen, z. B. `"R-2026-000124"`) — **nicht** als `int`. +- **Datumswerte** werden als `java.time.LocalDate` geführt. +- **Mengen** werden als `int` (Stückzahl) geführt. +- **Steuersätze** werden als `BigDecimal` als Faktor geführt (z. B. `0.19`). + +#### `enum DokumentStatus` +`{ ENTWURF, OFFEN, VERSENDET, STORNIERT }` + +#### Klasse `Dokumentposition` +| Attribut | Java-Typ | Beschreibung | +|-------------------|---------------|--------------| +| produktReferenz | `String` | Produktnummer des referenzierten Produkts (Gruppe B) | +| bezeichnung | `String` | Snapshot der Produktbezeichnung zum Erstellzeitpunkt | +| menge | `int` | Stückzahl (> 0) | +| einzelpreisNetto | `BigDecimal` | Snapshot Netto-Einzelpreis (Scale 2) | +| steuersatz | `BigDecimal` | Snapshot Steuersatz, z. B. `0.19` | +| positionssummeNetto | `BigDecimal` | `einzelpreisNetto * menge` (Scale 2) | + +#### Abstrakte Klasse `Dokument` +| Attribut | Java-Typ | Beschreibung | +|----------------|----------------------------|--------------| +| belegnummer | `String` | eindeutig, vom System generiert | +| datum | `LocalDate` | Erstelldatum | +| kundenReferenz | `String` | Kundennummer (Gruppe C) | +| positionen | `List` | 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 + + + +![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 + + + +![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. diff --git a/Pflichtenheft_GruppeA.pdf b/Pflichtenheft_GruppeA.pdf new file mode 100644 index 0000000..b91933b Binary files /dev/null and b/Pflichtenheft_GruppeA.pdf differ diff --git a/Pflichtenheft_GruppeB.md b/Pflichtenheft_GruppeB.md new file mode 100644 index 0000000..beeed17 --- /dev/null +++ b/Pflichtenheft_GruppeB.md @@ -0,0 +1,503 @@ +--- +title: "Pflichtenheft" +subtitle: "Desktop-Fakturierungsanwendung — Gruppe B: Verwaltung von Produkten" +author: + - Team 1 – Gruppe B +version: "1.0" +lang: de-DE +toc: true +toc-depth: 3 +numbersections: false +papersize: a4 +geometry: "margin=3cm" +fontsize: 12pt +linestretch: 1.5 +mainfont: "Times New Roman" +sansfont: "Arial" +monofont: "DejaVu Sans Mono" +header-includes: | + \usepackage{fancyhdr} + \usepackage{lastpage} + \pagestyle{fancy} + \fancyhf{} + \fancyhead[L]{Team 1 – Gruppe B} + \fancyhead[C]{Pflichtenheft} + \fancyhead[R]{Version 1.0} + \fancyfoot[C]{\thepage\ /\ \pageref{LastPage}} + \renewcommand{\headrulewidth}{0.4pt} + \renewcommand{\footrulewidth}{0pt} +--- + +\newpage + ++-----------------------------+-------------------------+-------------------------+ +| Autor | Prüfer | Freigebender | ++=============================+=========================+=========================+ +| Berhane, Meron\ | Prof. Dr. Marmitt, Gerd | Prof. Dr. Marmitt, Gerd | +| SchulzeAmeling, Jan-Micah\ | | | +| Volz, Jessica | | | ++-----------------------------+-------------------------+-------------------------+ +| Gruppe B (Produkte) | Modulverantwortlicher | Modulverantwortlicher | ++-----------------------------+-------------------------+-------------------------+ +| 10.06.2026 | 10.06.2026 | 10.06.2026 | ++-----------------------------+-------------------------+-------------------------+ + +**Freigabevermerk:** Dieses Dokument ist nach Prüfung und Freigabe durch den +Modulverantwortlichen verbindliche Spezifikationsgrundlage für die Implementierung +und den Modultest der Komponente *Verwaltung von Produkten*. + +## Dokumentenhistorie + +| Version | Datum | Autor | Grund der Änderung | +|---------|------------|----------------------------------------------------|---------------------| +| 1.0 | 10.06.2026 | Meron Berhane, Jan-Micah SchulzeAmeling, Jessica Volz | Initiale Erstellung | + +\newpage + +## 1. Einleitung + +### 1.1 Zweck des Dokuments +Dieses Pflichtenheft (System Requirements Specification, SRS) beschreibt aus Sicht des +Auftragnehmers, **wie** die Komponente *Verwaltung von Produkten* der +Desktop-Fakturierungsanwendung die Anforderungen des Lastenhefts (v1.3) erfüllt. Es +konkretisiert die fachlichen Anforderungen in testbare Systemanforderungen und dient als +direkte Grundlage für Design, Implementierung sowie den Komponenten- bzw. Modultestplan +(Kapitel 10). + +### 1.2 Ziel +Ziel dieses Pflichtenhefts ist die vollständige und testbare Spezifikation der Verwaltung +von Produktstammdaten: Anlegen, Ändern, Löschen (mit Löschsperre) sowie Suchen und +Auflisten von Produkten, einschließlich der Vergabe eindeutiger Produktnummern, der +Validierung der Eingaben und der Bereitstellung der Produktdaten für den Dokumentenzyklus +(Gruppe A). + +### 1.3 Geltungsbereich +Dieses Dokument gilt für die Komponente **Gruppe B — Verwaltung von Produkten**. Die +Gesamtanwendung wird arbeitsteilig in vier Komponenten entwickelt; jede Untergruppe pflegt +ein eigenes Pflichtenheft: + +| Gruppe | Komponente | Eigenes Pflichtenheft | +|--------|-------------------------|-----------------------| +| A | Prozess / Dokumentenzyklus | separat | +| B | Verwaltung von Produkten | **dieses Dokument** | +| C | Verwaltung von Kunden | separat | +| D | Programmoberfläche | separat | + +Die Komponente B **stellt** Produktstammdaten lesend für den Dokumentenzyklus (Gruppe A) +über eine definierte interne Schnittstelle bereit (Kapitel 6.2) und wird über die +Programmoberfläche (Gruppe D) bedient. Die Erzeugung von Belegen, die Snapshot-Bildung +von Preisen und Steuersätzen in Dokumentpositionen (GR-03) sowie die Kundenverwaltung +(Gruppe C) sind **nicht** Gegenstand dieses Dokuments. + +### 1.4 Definitionen und Abkürzungen +Fachbegriffe (Produkt, Dokumentposition, Snapshot, GoBD, DSGVO, CRUD, …) sind im Glossar +des Lastenhefts (§ 8.1) definiert und gelten unverändert. Dokumentspezifische Abkürzungen +siehe Kapitel 11. + +### 1.5 Referenzen +- Lastenheft „Desktop-Fakturierungsanwendung", Team 1, Version 1.3, 09.06.2026 +- Project Charter, Team 1, Version 1.3, 14.05.2026 +- Pflichtenheft Gruppe A „Prozess / Dokumentenzyklus", Version 1.0, 09.06.2026 +- GoBD — Grundsätze zur ordnungsmäßigen Führung und Aufbewahrung von Büchern +- DSGVO — EU-Verordnung 2016/679 +- Vorlesungsunterlagen Software Engineering 1 (SoSe 2026), Foliensatz „Lasten- und Pflichtenheft" + +--- + +## 2. Systemüberblick + +### 2.1 Kurzbeschreibung +Die Anwendung ist eine **Einzelplatz-Stand-Alone-Desktop-Anwendung** mit **lokaler +Datenhaltung** (keine Cloud, kein Server). Die Bedienung erfolgt über eine **minimale +grafische Benutzeroberfläche** (Gruppe D), über die die Funktionalität der +Produktverwaltung zugänglich gemacht wird. + +Die Komponente *Verwaltung von Produkten* stellt die Stammdatenpflege des Produktkatalogs +bereit: Anlegen, Ändern und Löschen von Produkten, Vergabe eindeutiger Produktnummern, +Validierung der Eingaben, Suche und sortierte Auflistung sowie der lesende Zugriff für die +Dokumenterstellung (Gruppe A) und der Export der Produktstammdaten in einem offenen Format. + +### 2.2 Abgrenzung (Was gehört dazu / was nicht) +**Im Umfang dieser Komponente:** + +- Anlegen von Produkten mit Pflicht- und optionalen Feldern +- Vergabe eindeutiger, vom System generierter Produktnummern +- Ändern von Produktdaten (Bezeichnung, Preis, Steuersatz, …) +- Löschen von Produkten inkl. Löschsperre bei referenzierten Produkten +- Suchen und sortiertes Auflisten von Produkten +- Bereitstellung des lesenden Zugriffs für Gruppe A (`ProduktService`) +- Export der Produktstammdaten in einem offenen, dokumentierten Format (Anteil an Q-08) + +**Nicht im Umfang dieser Komponente:** + +- Erstellung und Statusführung von Belegen (Gruppe A) +- Snapshot-Bildung von Preis/Steuersatz in Dokumentpositionen (GR-03, Gruppe A) +- Verwaltung von Kunden (Gruppe C) +- Aufbau und Layout der GUI (Gruppe D) +- Lagerverwaltung, Bestandsführung, Webshop-Anbindung (LH-Nichtziele) + +### 2.3 Grobe Systemfunktionen +Erfassen eines Produkts → Validieren der Eingaben → Vergeben der Produktnummer → +Persistieren → Suchen/Auflisten → Ändern → Löschen (mit Referenzprüfung) → Export. + +### 2.4 UML-Bezug +Ein gemeinsames Use-Case-Diagramm aller Gruppen gibt den Überblick über die Akteure und +Ziele. Die für Gruppe B relevanten Use Cases sind: *Produkt anlegen*, *Produktdaten +ändern*, *Produkt löschen* und *Produkte suchen und auflisten*. Die detaillierte logische +Architektur dieser Komponente folgt in Kapitel 7. + +--- + +## 3. Stakeholder und Kontext +Stakeholder und Systemkontext sind im Lastenheft (§ 2, § 3) beschrieben und gelten +unverändert. Für diese Komponente ist der maßgebliche Akteur: + +- **Anwender:in** — natürliche Person (Selbstständige:r, Freiberufler:in, + Kleinstunternehmer:in), die den Produktkatalog eigenverantwortlich pflegt. + +Angrenzende Systeme/Komponenten: lokales Dateisystem (Persistenz, Datenexport) sowie +intern die Komponenten Dokumentenzyklus (A, lesender Konsument der Produktdaten) und +Programmoberfläche (D). + +--- + +## 4. Funktionale Anforderungen + +Die Anforderungen sind nach CRUD-Operationen gruppiert und mit den Satzschablonen des +Foliensatzes formuliert. Jede Anforderung ist eindeutig, vollständig, widerspruchsfrei und +verifizierbar. + +> **Produktnummern (übergreifend):** Produktnummern sind **eindeutig** und werden +> **vom System generiert** (nicht durch den Anwender eingegeben). Sie werden als +> `String` geführt, **nicht** als `int`, weil die Nummern ein festes Format mit +> Präfix und führenden Nullen besitzen (z. B. `P-000042`); ein ganzzahliger Typ +> würde führende Nullen verlieren. Die Nummer wird fortlaufend auf Basis der höchsten +> bisher vergebenen Nummer ermittelt und ist nach der Vergabe **unveränderlich**. +> Anders als bei Rechnungsnummern (GR-01, Gruppe A) besteht keine +> Lückenlosigkeits-Pflicht. + +### 4.1 Produkt anlegen (aus BA-05) + +**F-01:** Das System MUSS es der Anwender:in ERMÖGLICHEN, ein neues Produkt mit den +Pflichtfeldern Bezeichnung, Netto-Einzelpreis und Steuersatz sowie den optionalen Feldern +Beschreibung und Einheit anzulegen. + +**F-02:** WENN ein Produkt gespeichert wird, DANN MUSS das System eine eindeutige +Produktnummer (Präfix `P-`, fortlaufend, führende Nullen) vergeben und anzeigen. + +**F-03:** WENN ein Produkt gespeichert wird, DANN MUSS das System die Eingaben validieren: +der Netto-Einzelpreis MUSS größer oder gleich `0.00` sein und der Steuersatz MUSS einem +der zulässigen Werte `{0.00, 0.07, 0.19}` entsprechen; andernfalls MUSS das Speichern +abgelehnt werden. + +**F-04:** WENN ein Pflichtfeld fehlt (keine Bezeichnung, kein Einzelpreis, kein +Steuersatz), DANN MUSS das System das Speichern ablehnen und das fehlende Pflichtfeld +benennen (Q-09). + +### 4.2 Produktdaten ändern (aus BA-06) + +**F-05:** Das System MUSS es der Anwender:in ERMÖGLICHEN, die Felder Bezeichnung, +Beschreibung, Netto-Einzelpreis, Steuersatz und Einheit eines bestehenden Produkts zu +ändern und persistent zu speichern. + +**F-06:** WENN ein Produkt geändert wird, DANN MUSS das System sicherstellen, dass +ausschließlich **neue** Dokumente den geänderten Wert verwenden; bereits erstellte +Dokumente bleiben unverändert, da Gruppe A Preis und Steuersatz als Snapshot in der +Dokumentposition ablegt (GR-02, GR-03). Die Komponente B speichert ausschließlich den +jeweils aktuellen Stand. + +**F-07:** Das System MUSS die Produktnummer nach der Vergabe vor jeder Änderung schützen; +ein Änderungsversuch an der Produktnummer MUSS abgelehnt werden. + +### 4.3 Produkt löschen (aus BA-07) + +**F-08:** Das System MUSS es der Anwender:in ERMÖGLICHEN, ein nicht referenziertes Produkt +nach einer Bestätigungsabfrage dauerhaft zu löschen. + +**F-09:** WENN das zu löschende Produkt in mindestens einer Dokumentposition referenziert +wird, DANN MUSS das System den Löschvorgang ablehnen und einen Hinweis anzeigen, dass das +Produkt in Dokumenten verwendet wird. + +**F-10:** WENN ein Löschvorgang ausgelöst wird, DANN MUSS das System vor dem Löschen über +die Schnittstelle `ProduktReferenzPruefung` (Gruppe A, Kapitel 6.2) prüfen, ob das Produkt +in Dokumentpositionen referenziert ist. + +### 4.4 Produkte suchen und auflisten (aus BA-08) + +**F-11:** Das System MUSS es der Anwender:in ERMÖGLICHEN, alle Produkte in einer nach +Bezeichnung sortierten Liste anzuzeigen. + +**F-12:** Das System MUSS es der Anwender:in ERMÖGLICHEN, Produkte über eine Suche nach +Bezeichnung oder Produktnummer zu filtern; die Suche MUSS Teilzeichenketten finden und +Groß-/Kleinschreibung ignorieren. + +**F-13:** WENN eine Suche ausgeführt wird, DANN MUSS das System das gefilterte Ergebnis +innerhalb der Vorgabe aus Q-02 anzeigen (siehe NF-PERF-01). + +### 4.5 Übergreifende Regeln und Dienste + +**F-14 (Bereitstellung für Gruppe A):** Das System MUSS eine lesende Schnittstelle +`ProduktService` bereitstellen, die es der Komponente Dokumentenzyklus (Gruppe A) +ERMÖGLICHT, ein Produkt anhand seiner Produktnummer abzurufen; existiert kein Produkt zur +Nummer, MUSS `null` zurückgegeben werden. + +**F-15 (Datenexport, Anteil an Q-08):** Das System MUSS es der Anwender:in ERMÖGLICHEN, +alle Produktstammdaten vollständig in ein offenes, dokumentiertes Format (CSV, UTF-8, +Semikolon-getrennt, mit Kopfzeile) in das lokale Dateisystem zu exportieren. + +--- + +## 5. Nicht-funktionale Anforderungen + +**NF-PERF-01 (aus Q-01/Q-02):** Das System MUSS Such- und Auflistungsergebnisse der +Produktverwaltung INNERHALB VON 1 SEKUNDE anzeigen, bei einem Datenbestand von bis zu +5.000 Produkten (Q-01) auf einem typischen Endanwender-PC. + +**NF-EXP-01 (aus Q-08, anteilig):** Das System MUSS den vollständigen Export der +Produktstammdaten (F-15) INNERHALB VON 30 SEKUNDEN abschließen, bei einem Datenbestand +gemäß Q-01. + +**NF-USE-01 (aus Q-09):** Das System MUSS fehlende Pflichtangaben im Formular „Produkt +anlegen/ändern" so markieren und benennen, dass mindestens 80 % der Testpersonen die +fehlende Eingabe ohne externe Hilfe im ersten Korrekturversuch ergänzen können (Nachweis +durch Usability-Test mit mind. 5 Testpersonen). + +**NF-SEC-01 (aus Q-06, anteilig):** Das System MUSS alle Produktdaten ausschließlich lokal +auf dem Anwender-PC ablegen; eine Übertragung an externe Dienste findet NICHT statt. + +--- + +## 6. Daten und Schnittstellen + +Dieses Kapitel ist direkter Input für den Modultestplan (Kapitel 10). Datentypen werden +bereits als Java-Typen angegeben. + +### 6.1 Datenobjekte und Datentypen + +**Designgrundsätze (konsistent zu Gruppe A):** + +- **Geldbeträge** werden als `java.math.BigDecimal` mit **Scale 2** und kaufmännischer + Rundung (`RoundingMode.HALF_UP`) geführt — **nicht** als `double` (Gleitkomma-Rundungs­fehler + wären für Beträge unzulässig). +- **Produktnummern** werden als `String` geführt (festes Format mit Präfix und führenden + Nullen, z. B. `"P-000042"`) — **nicht** als `int`. +- **Steuersätze** werden als `BigDecimal` als Faktor geführt (z. B. `0.19`); zulässige + Werte: `0.00`, `0.07`, `0.19`. + +#### Klasse `Produkt` +| Attribut | Java-Typ | Beschreibung | +|-------------------|---------------|--------------| +| produktnummer | `String` | eindeutig, vom System generiert, unveränderlich (F-02, F-07) | +| bezeichnung | `String` | Pflichtfeld, nicht leer | +| beschreibung | `String` (optional, `null`) | Freitext | +| einzelpreisNetto | `BigDecimal` | Pflichtfeld, Scale 2, ≥ 0.00 | +| steuersatz | `BigDecimal` | Pflichtfeld, Faktor aus `{0.00, 0.07, 0.19}` | +| einheit | `String` (optional, `null`) | z. B. `"Stück"`, `"Stunde"` | + +### 6.2 Schnittstellen + +**Externe Schnittstellen:** + +| ID | Schnittstelle | Zweck | +|-------|---------------------------|-------| +| IF-01 | Lokales Dateisystem | Persistenz der Produktstammdaten | +| IF-04 | Datenexport-Schnittstelle | Export der Produktstammdaten als CSV (F-15, Q-08) | + +**Interne Schnittstellen (zu anderen Komponenten), als Java-Interfaces skizziert:** + +```java +// Von Gruppe B IMPLEMENTIERT, von Gruppe A genutzt (lesender Zugriff) +public interface ProduktService { + Produkt findeProdukt(String produktnummer); // null, wenn nicht vorhanden +} + +// Von Gruppe A BEREITGESTELLT, von Gruppe B genutzt (Löschsperre, F-10) +public interface ProduktReferenzPruefung { + boolean istProduktReferenziert(String produktnummer); +} +``` + +**Komponenteninterne Dienste:** + +```java +public interface ProduktnummernGenerator { + // liefert die nächste fortlaufende Produktnummer, z. B. "P-000042" + String naechsteNummer(); +} + +public interface ProduktRepository { + Produkt speichere(Produkt produkt); + void loesche(String produktnummer); + List alleSortiertNachBezeichnung(); + List 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 + + + +![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 + + + +![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. diff --git a/Pflichtenheft_GruppeB.pdf b/Pflichtenheft_GruppeB.pdf new file mode 100644 index 0000000..1765f7b Binary files /dev/null and b/Pflichtenheft_GruppeB.pdf differ diff --git a/Pflichtenheft_GruppeC.md b/Pflichtenheft_GruppeC.md new file mode 100644 index 0000000..b7548c7 --- /dev/null +++ b/Pflichtenheft_GruppeC.md @@ -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 alleSortiertNachName(); + List 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 + + + +![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 + + + +![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. diff --git a/Pflichtenheft_GruppeC.pdf b/Pflichtenheft_GruppeC.pdf new file mode 100644 index 0000000..9cc1fd0 Binary files /dev/null and b/Pflichtenheft_GruppeC.pdf differ diff --git a/Pflichtenheft_GruppeD.md b/Pflichtenheft_GruppeD.md new file mode 100644 index 0000000..a01e70f --- /dev/null +++ b/Pflichtenheft_GruppeD.md @@ -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` | 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 positionen, + LocalDate rechnungsdatum, LocalDate zahlungsziel); + void storniere(String rechnungsnummer); + List 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 suche(String suchbegriff); +} + +// Gruppe B — Produktverwaltung (genutzt von F-03, F-04, Wizard-Schritt 2) +public interface ProduktService { + Produkt findeProdukt(String produktnummer); + List 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 + + + +![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 + + + +![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. diff --git a/Pflichtenheft_GruppeD.pdf b/Pflichtenheft_GruppeD.pdf new file mode 100644 index 0000000..65cc125 Binary files /dev/null and b/Pflichtenheft_GruppeD.pdf differ diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..8e3e2bb --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + de.team1 + fakturierung + Desktop-Fakturierungsanwendung (Team 1) + 1.0.0 + Einzelplatz-Fakturierungsanwendung gemäß Lastenheft v1.3 und den + Pflichtenheften der Gruppen A (Dokumentenzyklus), B (Produkte), + C (Kunden) und D (Programmoberfläche). + + + + maven-surefire-plugin + 3.2.5 + + + maven-jar-plugin + 3.4.1 + + + + de.team1.faktura.Main + + + + + + maven-shade-plugin + 3.5.2 + + + package + + shade + + + + + de.team1.faktura.Main + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + junit-jupiter-api + org.junit.jupiter + + + junit-jupiter-params + org.junit.jupiter + + + junit-jupiter-engine + org.junit.jupiter + + + + + + 21 + UTF-8 + 3.0.3 + 5.10.2 + 2.17.2 + + diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..d167fb4 --- /dev/null +++ b/mvnw @@ -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" "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..e28548b --- /dev/null +++ b/mvnw.cmd @@ -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% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5b20cc5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + de.team1 + fakturierung + 1.0.0 + jar + + Desktop-Fakturierungsanwendung (Team 1) + + Einzelplatz-Fakturierungsanwendung gemäß Lastenheft v1.3 und den + Pflichtenheften der Gruppen A (Dokumentenzyklus), B (Produkte), + C (Kunden) und D (Programmoberfläche). + + + + UTF-8 + 21 + 2.17.2 + 5.10.2 + 3.0.3 + + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + + + org.apache.pdfbox + pdfbox + ${pdfbox.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + + de.team1.faktura.Main + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + + shade + + + + + de.team1.faktura.Main + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/src/main/java/de/team1/faktura/Main.java b/src/main/java/de/team1/faktura/Main.java new file mode 100644 index 0000000..8bd5b35 --- /dev/null +++ b/src/main/java/de/team1/faktura/Main.java @@ -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); + }); + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/Angebot.java b/src/main/java/de/team1/faktura/dokumente/Angebot.java new file mode 100644 index 0000000..b6adedf --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Angebot.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/Auftragsbestaetigung.java b/src/main/java/de/team1/faktura/dokumente/Auftragsbestaetigung.java new file mode 100644 index 0000000..942a3f6 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Auftragsbestaetigung.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/BelegnummernGenerator.java b/src/main/java/de/team1/faktura/dokumente/BelegnummernGenerator.java new file mode 100644 index 0000000..94598be --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/BelegnummernGenerator.java @@ -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); +} diff --git a/src/main/java/de/team1/faktura/dokumente/Belegtyp.java b/src/main/java/de/team1/faktura/dokumente/Belegtyp.java new file mode 100644 index 0000000..f174b4e --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Belegtyp.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/Dokument.java b/src/main/java/de/team1/faktura/dokumente/Dokument.java new file mode 100644 index 0000000..af17f34 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Dokument.java @@ -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). + * + *

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). + * + *

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 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 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 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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/DokumentReferenzPruefung.java b/src/main/java/de/team1/faktura/dokumente/DokumentReferenzPruefung.java new file mode 100644 index 0000000..b595261 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/DokumentReferenzPruefung.java @@ -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())); + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/DokumentRepository.java b/src/main/java/de/team1/faktura/dokumente/DokumentRepository.java new file mode 100644 index 0000000..4c6d604 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/DokumentRepository.java @@ -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 alle(); +} diff --git a/src/main/java/de/team1/faktura/dokumente/DokumentService.java b/src/main/java/de/team1/faktura/dokumente/DokumentService.java new file mode 100644 index 0000000..a175d7f --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/DokumentService.java @@ -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 positionen, LocalDate gueltigBis); + + /** Erstellt eine Auftragsbestätigung ohne Vorgängerbeleg (F-05, F-06). */ + Auftragsbestaetigung erstelleAuftragsbestaetigung(String kundenNr, List positionen); + + /** Erstellt einen Lieferschein ohne Vorgängerbeleg (F-08, F-09). */ + Lieferschein erstelleLieferschein(String kundenNr, List 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 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 alleDokumente(); + + /** Alle Rechnungen im Status {@code OFFEN} (F-20). */ + List offeneRechnungen(); + + /** Berechnet die Summen für die Wizard-Zusammenfassung (D-F-12), ohne zu speichern. */ + Summen berechneSummen(List positionen); + + /** Exportiert den Beleg als PDF in das lokale Dateisystem (F-04, F-07, F-10, F-15). */ + void exportierePdf(String belegnummer, Path zielDatei); +} diff --git a/src/main/java/de/team1/faktura/dokumente/DokumentStatus.java b/src/main/java/de/team1/faktura/dokumente/DokumentStatus.java new file mode 100644 index 0000000..881d2b9 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/DokumentStatus.java @@ -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 +} diff --git a/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java b/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java new file mode 100644 index 0000000..2354239 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Dokumentposition.java @@ -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 Snapshot 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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/EinfacherBelegnummernGenerator.java b/src/main/java/de/team1/faktura/dokumente/EinfacherBelegnummernGenerator.java new file mode 100644 index 0000000..01d1b8a --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/EinfacherBelegnummernGenerator.java @@ -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 --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 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); + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java b/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java new file mode 100644 index 0000000..1622527 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java @@ -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 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>() { })); + } 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 alle() { + return dokumente.stream() + .sorted(Comparator.comparing(Dokument::getBelegnummer)) + .toList(); + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/Lieferschein.java b/src/main/java/de/team1/faktura/dokumente/Lieferschein.java new file mode 100644 index 0000000..b54e56c --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Lieferschein.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java b/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java new file mode 100644 index 0000000..463a67b --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/PdfBoxPdfExporter.java @@ -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(); + } + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/PdfExporter.java b/src/main/java/de/team1/faktura/dokumente/PdfExporter.java new file mode 100644 index 0000000..e09552c --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/PdfExporter.java @@ -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); +} diff --git a/src/main/java/de/team1/faktura/dokumente/Positionsangabe.java b/src/main/java/de/team1/faktura/dokumente/Positionsangabe.java new file mode 100644 index 0000000..59b80c1 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Positionsangabe.java @@ -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) { +} diff --git a/src/main/java/de/team1/faktura/dokumente/Rechnung.java b/src/main/java/de/team1/faktura/dokumente/Rechnung.java new file mode 100644 index 0000000..166e83a --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Rechnung.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java new file mode 100644 index 0000000..3629bb5 --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java @@ -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 positionen, LocalDate gueltigBis) { + Kunde kunde = pruefeKunde(kundenNr); + List 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 positionen) { + Kunde kunde = pruefeKunde(kundenNr); + List 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 positionen, LocalDate lieferdatum) { + Kunde kunde = pruefeKunde(kundenNr); + List 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 positionen, + LocalDate rechnungsdatum, LocalDate zahlungsziel) { + Kunde kunde = pruefeKunde(kundenNr); + List 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 alleDokumente() { + return repository.alle(); + } + + @Override + public List offeneRechnungen() { + return repository.alle().stream() + .filter(d -> d instanceof Rechnung && d.getStatus() == DokumentStatus.OFFEN) + .map(d -> (Rechnung) d) + .toList(); + } + + @Override + public Summen berechneSummen(List 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 bauePositionen(List positionen) { + if (positionen == null || positionen.isEmpty()) { + throw new ValidierungsException("Position", + "Mindestens eine 'Position' ist erforderlich (F-18)."); + } + List 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; + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/Summen.java b/src/main/java/de/team1/faktura/dokumente/Summen.java new file mode 100644 index 0000000..cd4d69f --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/Summen.java @@ -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) { +} diff --git a/src/main/java/de/team1/faktura/gemeinsam/Csv.java b/src/main/java/de/team1/faktura/gemeinsam/Csv.java new file mode 100644 index 0000000..7e68feb --- /dev/null +++ b/src/main/java/de/team1/faktura/gemeinsam/Csv.java @@ -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 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); + } + } +} diff --git a/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java b/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java new file mode 100644 index 0000000..4467521 --- /dev/null +++ b/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/gemeinsam/LoeschAbgelehntException.java b/src/main/java/de/team1/faktura/gemeinsam/LoeschAbgelehntException.java new file mode 100644 index 0000000..0b9892e --- /dev/null +++ b/src/main/java/de/team1/faktura/gemeinsam/LoeschAbgelehntException.java @@ -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); + } +} diff --git a/src/main/java/de/team1/faktura/gemeinsam/ValidierungsException.java b/src/main/java/de/team1/faktura/gemeinsam/ValidierungsException.java new file mode 100644 index 0000000..ee0daa4 --- /dev/null +++ b/src/main/java/de/team1/faktura/gemeinsam/ValidierungsException.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/gui/BelegAktionen.java b/src/main/java/de/team1/faktura/gui/BelegAktionen.java new file mode 100644 index 0000000..4814d60 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/BelegAktionen.java @@ -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) { +} diff --git a/src/main/java/de/team1/faktura/gui/BelegDialog.java b/src/main/java/de/team1/faktura/gui/BelegDialog.java new file mode 100644 index 0000000..70217e5 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/BelegDialog.java @@ -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 typWahl = new JComboBox<>( + new Belegtyp[]{Belegtyp.ANGEBOT, Belegtyp.AUFTRAGSBESTAETIGUNG, Belegtyp.LIEFERSCHEIN}); + private final JComboBox kundenWahl = new JComboBox<>(); + private final JComboBox produktWahl = new JComboBox<>(); + private final JSpinner mengeWahl = new JSpinner(new SpinnerNumberModel(1, 1, 99999, 1)); + private final DefaultListModel positionsListenModel = new DefaultListModel<>(); + private final JList 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 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); + } + } +} diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenController.java b/src/main/java/de/team1/faktura/gui/DokumentListenController.java new file mode 100644 index 0000000..c847d50 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/DokumentListenController.java @@ -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 gefiltert(DokumentStatus statusFilter) { + return dokumentService.alleDokumente().stream() + .filter(d -> statusFilter == null || d.getStatus() == statusFilter) + .toList(); + } + + /** + * Verfügbare Aktionen je Beleg: Stornieren 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()); + } + } +} diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java new file mode 100644 index 0000000..22838a2 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java @@ -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 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 dokumente = new ArrayList<>(); + + void setze(List 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(); + }; + } + } +} diff --git a/src/main/java/de/team1/faktura/gui/HauptFenster.java b/src/main/java/de/team1/faktura/gui/HauptFenster.java new file mode 100644 index 0000000..95f002e --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/HauptFenster.java @@ -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 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 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); + } +} diff --git a/src/main/java/de/team1/faktura/gui/KundenPanel.java b/src/main/java/de/team1/faktura/gui/KundenPanel.java new file mode 100644 index 0000000..f948bc0 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/KundenPanel.java @@ -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 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 kunden = new ArrayList<>(); + + void setze(List 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(); + }; + } + } +} diff --git a/src/main/java/de/team1/faktura/gui/Meldung.java b/src/main/java/de/team1/faktura/gui/Meldung.java new file mode 100644 index 0000000..cc1906f --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/Meldung.java @@ -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); + } +} diff --git a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java new file mode 100644 index 0000000..89a402b --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java @@ -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 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); + } + } +} diff --git a/src/main/java/de/team1/faktura/gui/MeldungsTyp.java b/src/main/java/de/team1/faktura/gui/MeldungsTyp.java new file mode 100644 index 0000000..b2894c7 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/MeldungsTyp.java @@ -0,0 +1,9 @@ +package de.team1.faktura.gui; + +/** + * Art einer Benutzer-Meldung (Gruppe D, Kapitel 6.1). + */ +public enum MeldungsTyp { + ERFOLG, + FEHLER +} diff --git a/src/main/java/de/team1/faktura/gui/ModulPanel.java b/src/main/java/de/team1/faktura/gui/ModulPanel.java new file mode 100644 index 0000000..75d1d31 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/ModulPanel.java @@ -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(); +} diff --git a/src/main/java/de/team1/faktura/gui/PositionsEingabe.java b/src/main/java/de/team1/faktura/gui/PositionsEingabe.java new file mode 100644 index 0000000..3cae4a1 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/PositionsEingabe.java @@ -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) { +} diff --git a/src/main/java/de/team1/faktura/gui/ProduktPanel.java b/src/main/java/de/team1/faktura/gui/ProduktPanel.java new file mode 100644 index 0000000..3d4bc8c --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/ProduktPanel.java @@ -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 steuersatzWahl = new JComboBox<>(STEUERSAETZE); + private final JTextField einheitFeld = new JTextField(10); + private final JLabel nummerAnzeige = new JLabel("— neues Produkt —"); + private final Map 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 produkte = new ArrayList<>(); + + void setze(List 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(); + }; + } + } +} diff --git a/src/main/java/de/team1/faktura/gui/RechnungsWizardController.java b/src/main/java/de/team1/faktura/gui/RechnungsWizardController.java new file mode 100644 index 0000000..87ea364 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/RechnungsWizardController.java @@ -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 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 positionsangaben() { + return model.getPositionen().stream() + .map(p -> new Positionsangabe(p.produktnummer(), p.menge())) + .toList(); + } +} diff --git a/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java b/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java new file mode 100644 index 0000000..c560034 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/RechnungsWizardDialog.java @@ -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 kundenListenModel = new DefaultListModel<>(); + private final JList kundenListe = new JList<>(kundenListenModel); + + private final JComboBox produktWahl = new JComboBox<>(); + private final JSpinner mengeWahl = new JSpinner(new SpinnerNumberModel(1, 1, 99999, 1)); + private final DefaultListModel positionsListenModel = new DefaultListModel<>(); + private final JList 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]))); + } +} diff --git a/src/main/java/de/team1/faktura/gui/RechnungsWizardModel.java b/src/main/java/de/team1/faktura/gui/RechnungsWizardModel.java new file mode 100644 index 0000000..98c942d --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/RechnungsWizardModel.java @@ -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 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 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; + } +} diff --git a/src/main/java/de/team1/faktura/gui/StammdatenController.java b/src/main/java/de/team1/faktura/gui/StammdatenController.java new file mode 100644 index 0000000..4c39e5a --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/StammdatenController.java @@ -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 sucheKunden(String suchbegriff) { + return kundenService.suche(suchbegriff); + } + + public List sucheProdukte(String suchbegriff) { + return produktService.suche(suchbegriff); + } +} diff --git a/src/main/java/de/team1/faktura/gui/WizardSchritt.java b/src/main/java/de/team1/faktura/gui/WizardSchritt.java new file mode 100644 index 0000000..ac17725 --- /dev/null +++ b/src/main/java/de/team1/faktura/gui/WizardSchritt.java @@ -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 +} diff --git a/src/main/java/de/team1/faktura/kunden/EinfacherKundennummernGenerator.java b/src/main/java/de/team1/faktura/kunden/EinfacherKundennummernGenerator.java new file mode 100644 index 0000000..2e49531 --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/EinfacherKundennummernGenerator.java @@ -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++); + } +} diff --git a/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java b/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java new file mode 100644 index 0000000..ae346f6 --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java @@ -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 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>() { })); + } 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 alleSortiertNachName() { + return kunden.stream() + .sorted(Comparator.comparing(Kunde::getName, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + @Override + public List 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(); + } +} diff --git a/src/main/java/de/team1/faktura/kunden/Kunde.java b/src/main/java/de/team1/faktura/kunden/Kunde.java new file mode 100644 index 0000000..c721e88 --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/Kunde.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/kunden/KundenCsvExport.java b/src/main/java/de/team1/faktura/kunden/KundenCsvExport.java new file mode 100644 index 0000000..8976ddb --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/KundenCsvExport.java @@ -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 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); + } +} diff --git a/src/main/java/de/team1/faktura/kunden/KundenReferenzPruefung.java b/src/main/java/de/team1/faktura/kunden/KundenReferenzPruefung.java new file mode 100644 index 0000000..c58389b --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/KundenReferenzPruefung.java @@ -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); +} diff --git a/src/main/java/de/team1/faktura/kunden/KundenRepository.java b/src/main/java/de/team1/faktura/kunden/KundenRepository.java new file mode 100644 index 0000000..893bcd5 --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/KundenRepository.java @@ -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 alleSortiertNachName(); + + /** Suche über Name ODER Kundennummer (Teilstring, case-insensitive). */ + List suche(String suchbegriff); +} diff --git a/src/main/java/de/team1/faktura/kunden/KundenService.java b/src/main/java/de/team1/faktura/kunden/KundenService.java new file mode 100644 index 0000000..e349af8 --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/KundenService.java @@ -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 suche(String suchbegriff); +} diff --git a/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java b/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java new file mode 100644 index 0000000..c8a012b --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java @@ -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 alleSortiertNachName() { + return repository.alleSortiertNachName(); + } + + @Override + public List 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."); + } + } +} diff --git a/src/main/java/de/team1/faktura/kunden/KundennummernGenerator.java b/src/main/java/de/team1/faktura/kunden/KundennummernGenerator.java new file mode 100644 index 0000000..4bf279b --- /dev/null +++ b/src/main/java/de/team1/faktura/kunden/KundennummernGenerator.java @@ -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(); +} diff --git a/src/main/java/de/team1/faktura/produkte/EinfacherProduktnummernGenerator.java b/src/main/java/de/team1/faktura/produkte/EinfacherProduktnummernGenerator.java new file mode 100644 index 0000000..743c11c --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/EinfacherProduktnummernGenerator.java @@ -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++); + } +} diff --git a/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java b/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java new file mode 100644 index 0000000..b8f7b3f --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java @@ -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 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>() { })); + } 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 alleSortiertNachBezeichnung() { + return produkte.stream() + .sorted(Comparator.comparing(Produkt::getBezeichnung, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + @Override + public List 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(); + } +} diff --git a/src/main/java/de/team1/faktura/produkte/Produkt.java b/src/main/java/de/team1/faktura/produkte/Produkt.java new file mode 100644 index 0000000..3233ccb --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/Produkt.java @@ -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; + } +} diff --git a/src/main/java/de/team1/faktura/produkte/ProduktCsvExport.java b/src/main/java/de/team1/faktura/produkte/ProduktCsvExport.java new file mode 100644 index 0000000..95cc718 --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/ProduktCsvExport.java @@ -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 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); + } +} diff --git a/src/main/java/de/team1/faktura/produkte/ProduktReferenzPruefung.java b/src/main/java/de/team1/faktura/produkte/ProduktReferenzPruefung.java new file mode 100644 index 0000000..06d1799 --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/ProduktReferenzPruefung.java @@ -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); +} diff --git a/src/main/java/de/team1/faktura/produkte/ProduktRepository.java b/src/main/java/de/team1/faktura/produkte/ProduktRepository.java new file mode 100644 index 0000000..8d7b553 --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/ProduktRepository.java @@ -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 alleSortiertNachBezeichnung(); + + /** Suche über Bezeichnung ODER Produktnummer (Teilstring, case-insensitive). */ + List suche(String suchbegriff); +} diff --git a/src/main/java/de/team1/faktura/produkte/ProduktService.java b/src/main/java/de/team1/faktura/produkte/ProduktService.java new file mode 100644 index 0000000..70319ac --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/ProduktService.java @@ -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 suche(String suchbegriff); +} diff --git a/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java b/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java new file mode 100644 index 0000000..9eeb4de --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java @@ -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 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 alleSortiertNachBezeichnung() { + return repository.alleSortiertNachBezeichnung(); + } + + @Override + public List 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."); + } + } +} diff --git a/src/main/java/de/team1/faktura/produkte/ProduktnummernGenerator.java b/src/main/java/de/team1/faktura/produkte/ProduktnummernGenerator.java new file mode 100644 index 0000000..331d7f6 --- /dev/null +++ b/src/main/java/de/team1/faktura/produkte/ProduktnummernGenerator.java @@ -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(); +} diff --git a/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java b/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java new file mode 100644 index 0000000..6218135 --- /dev/null +++ b/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java @@ -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 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 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 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()); + } +} diff --git a/src/test/java/de/team1/faktura/dokumente/TestBelege.java b/src/test/java/de/team1/faktura/dokumente/TestBelege.java new file mode 100644 index 0000000..3898cf4 --- /dev/null +++ b/src/test/java/de/team1/faktura/dokumente/TestBelege.java @@ -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; + } +} diff --git a/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java b/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java new file mode 100644 index 0000000..059dfcd --- /dev/null +++ b/src/test/java/de/team1/faktura/gui/OberflaechenControllerTest.java @@ -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 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 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 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 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 dokumente = new ArrayList<>(); + + @Override + public Rechnung erstelleRechnung(String kundenNr, List 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 positionen) { + return summen; + } + + @Override + public List alleDokumente() { + return dokumente; + } + + @Override + public Angebot erstelleAngebot(String kundenNr, List positionen, + LocalDate gueltigBis) { + return new Angebot(); + } + + @Override + public Auftragsbestaetigung erstelleAuftragsbestaetigung(String kundenNr, + List positionen) { + return new Auftragsbestaetigung(); + } + + @Override + public Lieferschein erstelleLieferschein(String kundenNr, List positionen, + LocalDate lieferdatum) { + return new Lieferschein(); + } + + @Override + public Dokument erzeugeFolgebeleg(String belegnummer) { + return null; + } + + @Override + public void versende(String belegnummer) { + } + + @Override + public List offeneRechnungen() { + return List.of(); + } + + @Override + public void exportierePdf(String belegnummer, Path zielDatei) { + } + } +} diff --git a/src/test/java/de/team1/faktura/kunden/KundenVerwaltungTest.java b/src/test/java/de/team1/faktura/kunden/KundenVerwaltungTest.java new file mode 100644 index 0000000..da5ca7f --- /dev/null +++ b/src/test/java/de/team1/faktura/kunden/KundenVerwaltungTest.java @@ -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 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 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 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 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 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;")); + } +} diff --git a/src/test/java/de/team1/faktura/produkte/ProduktVerwaltungTest.java b/src/test/java/de/team1/faktura/produkte/ProduktVerwaltungTest.java new file mode 100644 index 0000000..8027d70 --- /dev/null +++ b/src/test/java/de/team1/faktura/produkte/ProduktVerwaltungTest.java @@ -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 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 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 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 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 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;")); + } +}