diff --git a/Anforderungsabgleich.md b/Anforderungsabgleich.md new file mode 100644 index 0000000..a95cf2e --- /dev/null +++ b/Anforderungsabgleich.md @@ -0,0 +1,88 @@ +--- +title: "Anforderungsabgleich & Traceability-Matrix" +subtitle: "Desktop-Fakturierungsanwendung" +author: + - Team 1 +version: "1.0" +lang: de-DE +--- + +# Anforderungsabgleich Lastenheft ↔ Implementierung + +Dieses Dokument weist nach, dass die Implementierung alle Anforderungen des +[Lastenheft.md](Lastenheft.md) (v1.3) erfüllt. Es dient als Traceability-Matrix +(Anforderung ↔ Testfall ↔ Codebeleg) gemäß Abnahmebedingung Kap. 7.2 des Lastenhefts. + +Status-Legende: ✅ erfüllt · 📋 organisatorischer Nachweis erforderlich + +## 1. Fachliche Anforderungen (BA) + +| Anf. | Inhalt | Codebeleg | Testfall | Status | +|---|---|---|---|---| +| BA-01 | Kunde anlegen (Pflicht-/Optionalfelder, eindeutige Nr.) | `KundenVerwaltungsService.legeAn`, `EinfacherKundennummernGenerator` | KundenVerwaltungTest TC-01 | ✅ | +| BA-02 | Kundendaten ändern | `KundenVerwaltungsService.aendere` | KundenVerwaltungTest TC-07 | ✅ | +| BA-03 | Kunden löschen (mit Löschsperre) | `KundenVerwaltungsService.loescheKunde` | KundenVerwaltungTest TC-09/TC-10 | ✅ | +| BA-04 | Kunden suchen/auflisten (Name + Nr.) | `JsonKundenRepository.suche/alleSortiertNachName` | KundenVerwaltungTest TC-12/TC-13 | ✅ | +| BA-05 | Produkt anlegen (Preis, Steuersatz, eindeutige Nr.) | `ProduktVerwaltungsService.legeAn` | ProduktVerwaltungTest TC-01 | ✅ | +| BA-06 | Produktdaten ändern (Snapshot in Belegen) | `ProduktVerwaltungsService.aendere` | ProduktVerwaltungTest TC-06, DokumentzyklusTest TC-11 | ✅ | +| BA-07 | Produkte löschen (mit Löschsperre) | `ProduktVerwaltungsService.loescheProdukt` | ProduktVerwaltungTest TC-08/TC-09 | ✅ | +| BA-08 | Produkte suchen/auflisten (Bezeichnung + Nr.) | `JsonProduktRepository.suche/alleSortiertNachBezeichnung` | ProduktVerwaltungTest TC-11/TC-12 | ✅ | +| BA-09 | Angebot erstellen + PDF | `StandardDokumentService.erstelleAngebot`, `PdfBoxPdfExporter` | DokumentzyklusTest TC-13 (analog) | ✅ | +| BA-10 | Auftragsbestätigung erstellen + PDF | `StandardDokumentService.erstelleAuftragsbestaetigung` | DokumentzyklusTest TC-10 | ✅ | +| BA-11 | Lieferschein erstellen + PDF | `StandardDokumentService.erstelleLieferschein` | DokumentzyklusTest TC-10 (Folgebeleg) | ✅ | +| BA-12 | Rechnung erstellen (Nr., §14 UStG, Summen, Ziel) | `StandardDokumentService.erstelleRechnung` | DokumentzyklusTest TC-13 | ✅ | +| BA-13 | Geführte Rechnungserstellung (Wizard) | `RechnungsWizardController/-Dialog/-Model` | OberflaechenControllerTest TC-01…TC-08 | ✅ | +| BA-14 | Rechnung stornieren (Datum **und Benutzer**) | `Rechnung.storniere(datum, benutzer)`, `StandardDokumentService.SYSTEM_BENUTZER` | DokumentzyklusTest TC-09, OberflaechenControllerTest TC-10/TC-11 | ✅ | + +## 2. Geschäftsregeln (GR) + +| Anf. | Inhalt | Codebeleg | Testfall | Status | +|---|---|---|---|---| +| GR-01 | Lückenlose Rechnungsnummern | `EinfacherBelegnummernGenerator.ausRepository` | DokumentzyklusTest TC-04/TC-05 | ✅ | +| GR-02 | Unveränderlichkeit versendeter Dokumente | `Dokument.pruefeAenderbar` | DokumentzyklusTest TC-08 | ✅ | +| GR-03 | Steuerberechnung (Snapshot) | `Dokumentposition`, `Dokument.berechneSummen` | DokumentzyklusTest TC-01…TC-03, TC-11 | ✅ | +| GR-04 | Referenzielle Integrität Kunden | `KundenVerwaltungsService.loescheKunde` | KundenVerwaltungTest TC-10 | ✅ | +| GR-05 | Dokumentenzyklus-Konsistenz (Rückreferenz) | `StandardDokumentService.erzeugeFolgebeleg` | DokumentzyklusTest TC-10 | ✅ | +| GR-06 | Standard-Zahlungsziel 14 Tage | `StandardDokumentService.STANDARD_ZAHLUNGSZIEL_TAGE` | DokumentzyklusTest TC-06/TC-07 | ✅ | + +## 3. Qualitätsanforderungen (Q) + +| Anf. | Inhalt | Codebeleg / Nachweis | Testfall | Status | +|---|---|---|---|---| +| Q-01 | Referenzgröße 5.000 Kunden/Produkte | In-Memory-Repositories, Seeding im Test | PerformanceTest (Seeding) | ✅ | +| Q-02 | Suche/Auflistung ≤ 1 s | `JsonKundenRepository`/`JsonProduktRepository.suche` | PerformanceTest `q02Suche` | ✅ | +| Q-03 | PDF-Erstellung ≤ 2 s (50 Positionen) | `PdfBoxPdfExporter` | PerformanceTest `q03PdfErstellung` | ✅ | +| Q-04 | Anwendungsstart ≤ 5 s | Laden der drei JSON-Repositories | PerformanceTest `q04Anwendungsstart` | ✅ | +| Q-05 | Ersterstellung Rechnung < 10 min (≥5 Personen) | Wizard `RechnungsWizardController` | Usability-Test (organisatorisch) | 📋 | +| Q-06 | 100 % lokale Datenhaltung | nur lokales Dateisystem; `Desktop.mail()` = optionale IF-03 | Code-Inspektion / Netzwerk-Monitoring | ✅ | +| Q-07 | Unveränderlichkeit versendeter Rechnungen | `Dokument.pruefeAenderbar` | DokumentzyklusTest TC-08 | ✅ | +| Q-08 | Vollexport Stamm- **und Bewegungsdaten** ≤ 30 s | `KundenCsvExport`, `ProduktCsvExport`, **`DokumentCsvExport`** | DokumentCsvExportTest, PerformanceTest `q08Datenexport` | ✅ | +| Q-09 | Pflichtfeldhinweis (≥80 % Korrektur) | `ValidierungsException`, `MeldungsAnzeige`, Wizard-Validierung | OberflaechenControllerTest TC-03…TC-05; Usability-Test | ✅ (Code) / 📋 (Usability) | + +## 4. Akzeptanzkriterien (AC) + +| Anf. | bezogen auf | Testfall | Status | +|---|---|---|---| +| AC-01 | BA-01, BA-04 | KundenVerwaltungTest TC-01, TC-12 | ✅ | +| AC-02 | BA-02, BA-03, GR-04 | KundenVerwaltungTest TC-07, TC-10 | ✅ | +| AC-03 | BA-05, BA-06, GR-02 | ProduktVerwaltungTest TC-06, DokumentzyklusTest TC-11 | ✅ | +| AC-04 | BA-07, BA-08 | ProduktVerwaltungTest TC-08/TC-09, TC-11/TC-12; PerformanceTest `q02Suche` | ✅ | +| AC-05 | BA-09, Q-03 | DokumentzyklusTest (Angebot), PerformanceTest `q03PdfErstellung` | ✅ | +| AC-06 | BA-10 | DokumentzyklusTest TC-10 | ✅ | +| AC-07 | BA-11 | DokumentzyklusTest TC-10 (Folgebeleg) | ✅ | +| AC-08 | BA-12, GR-01, GR-06 | DokumentzyklusTest TC-04, TC-06, TC-13 | ✅ | +| AC-09 | BA-13 | OberflaechenControllerTest TC-02, TC-07, TC-08 | ✅ | +| AC-10 | BA-14 | DokumentzyklusTest TC-09, OberflaechenControllerTest TC-10/TC-11 | ✅ | +| AC-11 | Q-09 | OberflaechenControllerTest TC-03…TC-05; Usability-Test | ✅ (Code) / 📋 (Usability) | + +## 5. Offene organisatorische Punkte (Abnahmebedingung Kap. 7.2) + +Folgende Punkte sind nicht durch Code/automatisierte Tests abdeckbar und vor der +Endabnahme organisatorisch durchzuführen bzw. zu dokumentieren: + +- **Q-05 / AC-11 – Usability-Tests** mit mindestens 5 Testpersonen + (Ersterstellung einer Rechnung < 10 min; Pflichtfeldkorrektur ≥ 80 %). +- **M-07 – Abschlusspräsentation** und Abnahme durch den Auftraggeber. + +Alle übrigen Anforderungen (BA-01…BA-14, GR-01…GR-06, Q-01…Q-04, Q-06…Q-08 +sowie AC-01…AC-10) sind durch Code und automatisierte JUnit-Tests belegt. diff --git a/src/main/java/de/team1/faktura/Main.java b/src/main/java/de/team1/faktura/Main.java index 8bd5b35..59edd9f 100644 --- a/src/main/java/de/team1/faktura/Main.java +++ b/src/main/java/de/team1/faktura/Main.java @@ -1,5 +1,6 @@ package de.team1.faktura; +import de.team1.faktura.dokumente.DokumentCsvExport; import de.team1.faktura.dokumente.DokumentReferenzPruefung; import de.team1.faktura.dokumente.DokumentService; import de.team1.faktura.dokumente.EinfacherBelegnummernGenerator; @@ -78,7 +79,8 @@ public final class Main { HauptFenster fenster = new HauptFenster( new KundenPanel(kundenService, new KundenCsvExport(kundenRepository)), new ProduktPanel(produktService, new ProduktCsvExport(produktRepository)), - new DokumentListenPanel(dokumentService, kundenService, produktService)); + new DokumentListenPanel(dokumentService, kundenService, produktService, + new DokumentCsvExport(dokumentRepository))); fenster.setVisible(true); }); } diff --git a/src/main/java/de/team1/faktura/dokumente/DokumentCsvExport.java b/src/main/java/de/team1/faktura/dokumente/DokumentCsvExport.java new file mode 100644 index 0000000..22f8c2a --- /dev/null +++ b/src/main/java/de/team1/faktura/dokumente/DokumentCsvExport.java @@ -0,0 +1,91 @@ +package de.team1.faktura.dokumente; + +import de.team1.faktura.gemeinsam.Csv; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.time.LocalDate; +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 Belege (Bewegungsdaten) als CSV (Q-08, IF-04): UTF-8, + * Semikolon-getrennt, mit Kopfzeile. Es wird eine Zeile je Dokumentposition + * (denormalisiert) geschrieben, sodass der vollständige Datenbestand inklusive + * Kopf- und Positionsdaten enthalten ist. Belege ohne Positionen erscheinen mit + * leeren Positionsfeldern, damit kein Beleg verloren geht. + */ +public class DokumentCsvExport { + + private final DokumentRepository repository; + + public DokumentCsvExport(DokumentRepository repository) { + this.repository = repository; + } + + public void exportiereCsv(Path zielDatei) { + List zeilen = new ArrayList<>(); + zeilen.add(String.join(TRENNZEICHEN, + "belegnummer", "belegtyp", "datum", "status", "vorgaengerNr", + "kundenNr", "kundeName", "kundeAnschrift", + "summeNetto", "summeSteuer", "summeBrutto", + "zahlungsziel", "storniertAm", "storniertVon", + "produktnummer", "bezeichnung", "menge", + "einzelpreisNetto", "steuersatz", "positionssummeNetto", "positionssummeBrutto")); + for (Dokument dokument : repository.alle()) { + if (dokument.getPositionen().isEmpty()) { + zeilen.add(belegFelder(dokument) + TRENNZEICHEN + leerePositionsFelder()); + } else { + for (Dokumentposition position : dokument.getPositionen()) { + zeilen.add(belegFelder(dokument) + TRENNZEICHEN + positionsFelder(position)); + } + } + } + Csv.schreibe(zielDatei, zeilen); + } + + private static String belegFelder(Dokument dokument) { + Rechnung rechnung = dokument instanceof Rechnung r ? r : null; + return String.join(TRENNZEICHEN, + feld(dokument.getBelegnummer()), + feld(dokument.belegtyp().name()), + feld(datum(dokument.getDatum())), + feld(dokument.getStatus() == null ? null : dokument.getStatus().name()), + feld(dokument.getVorgaengerNr()), + feld(dokument.getKundenReferenz()), + feld(dokument.getKundeName()), + feld(dokument.getKundeAnschrift()), + feld(betrag(dokument.getSummeNetto())), + feld(betrag(dokument.getSummeSteuer())), + feld(betrag(dokument.getSummeBrutto())), + feld(rechnung == null ? null : datum(rechnung.getZahlungsziel())), + feld(rechnung == null ? null : datum(rechnung.getStorniertAm())), + feld(rechnung == null ? null : rechnung.getStorniertVon())); + } + + private static String positionsFelder(Dokumentposition position) { + return String.join(TRENNZEICHEN, + feld(position.getProduktReferenz()), + feld(position.getBezeichnung()), + feld(Integer.toString(position.getMenge())), + feld(betrag(position.getEinzelpreisNetto())), + feld(betrag(position.getSteuersatz())), + feld(betrag(position.getPositionssummeNetto())), + feld(betrag(position.getPositionssummeBrutto()))); + } + + private static String leerePositionsFelder() { + return TRENNZEICHEN.repeat(6); + } + + private static String datum(LocalDate datum) { + return datum == null ? null : datum.toString(); + } + + private static String betrag(BigDecimal wert) { + return wert == null ? null : wert.toPlainString(); + } +} diff --git a/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java b/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java index 1622527..6ee3e83 100644 --- a/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java +++ b/src/main/java/de/team1/faktura/dokumente/JsonDokumentRepository.java @@ -43,7 +43,9 @@ public class JsonDokumentRepository implements DokumentRepository { if (datei.getParent() != null) { Files.createDirectories(datei.getParent()); } - mapper.writeValue(datei.toFile(), dokumente); + // Über den Basistyp schreiben, damit die polymorphe Typ-ID ('typ') + // erhalten bleibt und Belege beim Neustart wieder geladen werden (IF-01). + mapper.writerFor(new TypeReference>() { }).writeValue(datei.toFile(), dokumente); } catch (IOException e) { throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e); } diff --git a/src/main/java/de/team1/faktura/dokumente/Rechnung.java b/src/main/java/de/team1/faktura/dokumente/Rechnung.java index 166e83a..6f6a57e 100644 --- a/src/main/java/de/team1/faktura/dokumente/Rechnung.java +++ b/src/main/java/de/team1/faktura/dokumente/Rechnung.java @@ -11,6 +11,7 @@ public class Rechnung extends Dokument { private LocalDate leistungsdatum; private LocalDate zahlungsziel; private LocalDate storniertAm; + private String storniertVon; @Override public Belegtyp belegtyp() { @@ -18,10 +19,10 @@ public class Rechnung extends Dokument { } /** - * Storniert eine offene Rechnung (F-19, F-20): Status wird - * {@code STORNIERT}, der Vorgang wird mit Datum protokolliert. + * Storniert eine offene Rechnung (BA-14, F-19, F-20): Status wird + * {@code STORNIERT}, der Vorgang wird mit Datum und Benutzer protokolliert. */ - public void storniere(LocalDate datum) { + public void storniere(LocalDate datum, String benutzer) { if (getStatus() != DokumentStatus.OFFEN) { throw new IllegalStateException( "Nur Rechnungen im Status OFFEN können storniert werden (F-19), " @@ -29,10 +30,16 @@ public class Rechnung extends Dokument { } setzeStatus(DokumentStatus.STORNIERT); this.storniertAm = datum; + this.storniertVon = benutzer; + } + + /** Storniert mit Datum, ohne Benutzerangabe (Rückwärtskompatibilität). */ + public void storniere(LocalDate datum) { + storniere(datum, null); } public void storniere() { - storniere(LocalDate.now()); + storniere(LocalDate.now(), null); } public LocalDate getLeistungsdatum() { @@ -56,4 +63,8 @@ public class Rechnung extends Dokument { public LocalDate getStorniertAm() { return storniertAm; } + + public String getStorniertVon() { + return storniertVon; + } } diff --git a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java index 3629bb5..b083110 100644 --- a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java +++ b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java @@ -24,6 +24,9 @@ public class StandardDokumentService implements DokumentService { /** Standard-Gültigkeit eines Angebots in Kalendertagen ab Erstelldatum (F-02). */ public static final int STANDARD_GUELTIGKEIT_TAGE = 30; + /** Protokollierter Benutzer einer Stornierung (BA-14); Einzelplatzbetrieb. */ + public static final String SYSTEM_BENUTZER = "Anwender"; + private final DokumentRepository repository; private final BelegnummernGenerator nummernGenerator; private final KundenService kundenService; @@ -164,7 +167,7 @@ public class StandardDokumentService implements DokumentService { throw new ValidierungsException("Beleg", "Nur Rechnungen können storniert werden (F-19)."); } - rechnung.storniere(); + rechnung.storniere(LocalDate.now(), SYSTEM_BENUTZER); repository.speichere(rechnung); } diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenController.java b/src/main/java/de/team1/faktura/gui/DokumentListenController.java index c847d50..44cbaa6 100644 --- a/src/main/java/de/team1/faktura/gui/DokumentListenController.java +++ b/src/main/java/de/team1/faktura/gui/DokumentListenController.java @@ -51,11 +51,27 @@ public class DokumentListenController { } try { dokumentService.storniere(rechnungsnummer); - return Meldung.erfolg("Die Rechnung " + rechnungsnummer + " wurde storniert."); + return Meldung.erfolg("Die Rechnung " + rechnungsnummer + " wurde storniert" + + protokoll(rechnungsnummer) + "."); } catch (ValidierungsException e) { return Meldung.fehler(e.getFeldname(), e.getMessage()); } catch (IllegalStateException e) { return Meldung.fehler(null, e.getMessage()); } } + + /** + * Liest das Storno-Protokoll (Datum, Benutzer) aus dem gespeicherten Beleg + * für die Erfolgsmeldung (BA-14); leer, falls nicht ermittelbar. + */ + private String protokoll(String rechnungsnummer) { + return dokumentService.alleDokumente().stream() + .filter(d -> d instanceof Rechnung && d.getBelegnummer().equals(rechnungsnummer)) + .map(d -> (Rechnung) d) + .filter(r -> r.getStorniertAm() != null) + .findFirst() + .map(r -> " am " + r.getStorniertAm() + + (r.getStorniertVon() == null ? "" : " durch " + r.getStorniertVon())) + .orElse(""); + } } diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java index 22838a2..698af1d 100644 --- a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java +++ b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java @@ -1,6 +1,7 @@ package de.team1.faktura.gui; import de.team1.faktura.dokumente.Dokument; +import de.team1.faktura.dokumente.DokumentCsvExport; import de.team1.faktura.dokumente.DokumentService; import de.team1.faktura.dokumente.DokumentStatus; import de.team1.faktura.gemeinsam.ValidierungsException; @@ -44,6 +45,7 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { private final DokumentService dokumentService; private final KundenService kundenService; private final ProduktService produktService; + private final DokumentCsvExport datenExport; private final DokumentListenController controller; private final JComboBox statusFilter = new JComboBox<>( @@ -60,10 +62,12 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { public DokumentListenPanel(DokumentService dokumentService, KundenService kundenService, - ProduktService produktService) { + ProduktService produktService, + DokumentCsvExport datenExport) { this.dokumentService = dokumentService; this.kundenService = kundenService; this.produktService = produktService; + this.datenExport = datenExport; this.controller = new DokumentListenController(dokumentService); baueOberflaeche(); aktualisiere(); @@ -81,8 +85,11 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { neueRechnung.addActionListener(e -> oeffneWizard()); JButton neuerBeleg = new JButton("Neuer Beleg…"); neuerBeleg.addActionListener(e -> oeffneBelegDialog()); + JButton datenExportKnopf = new JButton("Daten exportieren (CSV)…"); + datenExportKnopf.addActionListener(e -> exportiereDaten()); kopf.add(neueRechnung); kopf.add(neuerBeleg); + kopf.add(datenExportKnopf); add(kopf, BorderLayout.NORTH); tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); @@ -215,6 +222,18 @@ public class DokumentListenPanel extends JPanel implements ModulPanel { } } + /** Vollständiger Datenexport aller Belege als CSV (Q-08, IF-04). */ + private void exportiereDaten() { + JFileChooser auswahlDialog = new JFileChooser(); + auswahlDialog.setSelectedFile(new java.io.File("dokumente.csv")); + if (auswahlDialog.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) { + Path ziel = auswahlDialog.getSelectedFile().toPath(); + datenExport.exportiereCsv(ziel); + MeldungsAnzeige.zeige(this, Meldung.erfolg( + "Die Belegdaten wurden exportiert nach " + ziel), null); + } + } + /** Optionaler Druck über das Betriebssystem (IF-02). */ private void drucke() { Dokument dokument = auswahl(); diff --git a/src/test/java/de/team1/faktura/PerformanceTest.java b/src/test/java/de/team1/faktura/PerformanceTest.java new file mode 100644 index 0000000..5304c0c --- /dev/null +++ b/src/test/java/de/team1/faktura/PerformanceTest.java @@ -0,0 +1,146 @@ +package de.team1.faktura; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.team1.faktura.dokumente.Dokument; +import de.team1.faktura.dokumente.DokumentCsvExport; +import de.team1.faktura.dokumente.Dokumentposition; +import de.team1.faktura.dokumente.JsonDokumentRepository; +import de.team1.faktura.dokumente.PdfBoxPdfExporter; +import de.team1.faktura.dokumente.Rechnung; +import de.team1.faktura.gemeinsam.JsonPersistenz; +import de.team1.faktura.kunden.JsonKundenRepository; +import de.team1.faktura.kunden.Kunde; +import de.team1.faktura.kunden.KundenCsvExport; +import de.team1.faktura.produkte.JsonProduktRepository; +import de.team1.faktura.produkte.Produkt; +import de.team1.faktura.produkte.ProduktCsvExport; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +/** + * Performance-Nachweise (Q-01 bis Q-04, Q-08) gemäß Lastenheft. + * + *

Referenzgröße Q-01: 5.000 Kunden und 5.000 Produkte. Die Grenzwerte sind + * laufzeitabhängig; gemessen werden ausschließlich die Fachoperationen, das + * Befüllen der Testdateien erfolgt vorab in {@link #seed(Path)} und fließt nicht + * in die Messung ein. + */ +class PerformanceTest { + + private static final int ANZAHL_STAMMDATEN = 5_000; + private static final int ANZAHL_DOKUMENTE = 1_000; + + @TempDir + static Path datenVerzeichnis; + + private static Path kundenDatei; + private static Path produkteDatei; + private static Path dokumenteDatei; + + @BeforeAll + static void seed() throws IOException { + kundenDatei = datenVerzeichnis.resolve("kunden.json"); + produkteDatei = datenVerzeichnis.resolve("produkte.json"); + dokumenteDatei = datenVerzeichnis.resolve("dokumente.json"); + ObjectMapper mapper = JsonPersistenz.mapper(); + + List kunden = new ArrayList<>(ANZAHL_STAMMDATEN); + List produkte = new ArrayList<>(ANZAHL_STAMMDATEN); + for (int i = 1; i <= ANZAHL_STAMMDATEN; i++) { + Kunde kunde = new Kunde("Kunde " + i + " GmbH", "Hauptstr. " + i, "68163", "Mannheim"); + kunde.setKundennummer(String.format("K-%06d", i)); + kunden.add(kunde); + + Produkt produkt = new Produkt("Produkt " + i, new BigDecimal("49.99"), new BigDecimal("0.19")); + produkt.setProduktnummer(String.format("P-%06d", i)); + produkte.add(produkt); + } + + List dokumente = new ArrayList<>(ANZAHL_DOKUMENTE); + for (int i = 1; i <= ANZAHL_DOKUMENTE; i++) { + dokumente.add(rechnungMitPositionen(String.format("R-2026-%06d", i), 5)); + } + + mapper.writeValue(kundenDatei.toFile(), kunden); + mapper.writeValue(produkteDatei.toFile(), produkte); + mapper.writerFor(new TypeReference>() { }).writeValue(dokumenteDatei.toFile(), dokumente); + } + + @Test + @DisplayName("Q-04: Laden der drei Repositories (5.000/5.000/1.000) in ≤ 5 s") + void q04Anwendungsstart() { + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + new JsonKundenRepository(kundenDatei); + new JsonProduktRepository(produkteDatei); + new JsonDokumentRepository(dokumenteDatei); + }); + } + + @Test + @DisplayName("Q-02: Suche/Auflistung in Kunden- und Produktverwaltung in ≤ 1 s") + void q02Suche() { + JsonKundenRepository kundenRepository = new JsonKundenRepository(kundenDatei); + JsonProduktRepository produktRepository = new JsonProduktRepository(produkteDatei); + + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> { + assertFalse(kundenRepository.suche("Kunde 4999").isEmpty()); + assertFalse(kundenRepository.alleSortiertNachName().isEmpty()); + assertFalse(produktRepository.suche("Produkt 4999").isEmpty()); + assertFalse(produktRepository.alleSortiertNachBezeichnung().isEmpty()); + }); + } + + @Test + @DisplayName("Q-03: PDF-Export einer Rechnung mit 50 Positionen in ≤ 2 s") + void q03PdfErstellung() { + Rechnung rechnung = rechnungMitPositionen("R-2026-999999", 50); + PdfBoxPdfExporter exporter = new PdfBoxPdfExporter(); + Path ziel = datenVerzeichnis.resolve("pdf/R-2026-999999.pdf"); + + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> exporter.exportiere(rechnung, ziel)); + } + + @Test + @DisplayName("Q-08: Vollexport (Kunden, Produkte, Belege) in ≤ 30 s") + void q08Datenexport() { + JsonKundenRepository kundenRepository = new JsonKundenRepository(kundenDatei); + JsonProduktRepository produktRepository = new JsonProduktRepository(produkteDatei); + JsonDokumentRepository dokumentRepository = new JsonDokumentRepository(dokumenteDatei); + + assertTimeoutPreemptively(Duration.ofSeconds(30), () -> { + new KundenCsvExport(kundenRepository).exportiereCsv(datenVerzeichnis.resolve("export/kunden.csv")); + new ProduktCsvExport(produktRepository).exportiereCsv(datenVerzeichnis.resolve("export/produkte.csv")); + new DokumentCsvExport(dokumentRepository).exportiereCsv(datenVerzeichnis.resolve("export/dokumente.csv")); + }); + } + + private static Rechnung rechnungMitPositionen(String belegnummer, int anzahlPositionen) { + Rechnung rechnung = new Rechnung(); + rechnung.setBelegnummer(belegnummer); + rechnung.setDatum(LocalDate.of(2026, 6, 10)); + rechnung.setLeistungsdatum(LocalDate.of(2026, 6, 10)); + rechnung.setZahlungsziel(LocalDate.of(2026, 6, 24)); + rechnung.setzeKunde("K-000001", "Muster GmbH", "Hauptstr. 1, 68163 Mannheim"); + List positionen = new ArrayList<>(anzahlPositionen); + for (int i = 1; i <= anzahlPositionen; i++) { + positionen.add(new Dokumentposition("P-" + String.format("%06d", i), "Produkt " + i, + i, new BigDecimal("49.99"), new BigDecimal("0.19"))); + } + rechnung.setzePositionen(positionen); + return rechnung; + } +} diff --git a/src/test/java/de/team1/faktura/dokumente/DokumentCsvExportTest.java b/src/test/java/de/team1/faktura/dokumente/DokumentCsvExportTest.java new file mode 100644 index 0000000..72c981a --- /dev/null +++ b/src/test/java/de/team1/faktura/dokumente/DokumentCsvExportTest.java @@ -0,0 +1,43 @@ +package de.team1.faktura.dokumente; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Datenexport der Bewegungsdaten (Q-08, IF-04): vollständiger CSV-Export + * aller Belege mit einer Zeile je Position. + */ +class DokumentCsvExportTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("Q-08: Export schreibt Kopfzeile und je eine Zeile pro Dokumentposition") + void exportiertAlleBelegeMitPositionen() throws IOException { + JsonDokumentRepository repository = new JsonDokumentRepository(tempDir.resolve("dokumente.json")); + repository.speichere(TestBelege.rechnung("R-2026-000001", DokumentStatus.OFFEN)); + Rechnung storniert = TestBelege.rechnung("R-2026-000002", DokumentStatus.OFFEN); + storniert.storniere(java.time.LocalDate.of(2026, 6, 10), "Anwender"); + repository.speichere(storniert); + + Path ziel = tempDir.resolve("export/dokumente.csv"); + new DokumentCsvExport(repository).exportiereCsv(ziel); + + List zeilen = Files.readAllLines(ziel, StandardCharsets.UTF_8); + assertEquals(3, zeilen.size()); // Kopfzeile + 2 Rechnungen mit je 1 Position + assertTrue(zeilen.get(0).startsWith("belegnummer;belegtyp;datum;status")); + assertTrue(zeilen.stream().anyMatch(z -> z.contains("R-2026-000001"))); + assertTrue(zeilen.stream().anyMatch(z -> z.contains("STORNIERT") && z.contains("Anwender"))); + } +} diff --git a/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java b/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java index 6218135..5daafaa 100644 --- a/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java +++ b/src/test/java/de/team1/faktura/dokumente/DokumentzyklusTest.java @@ -161,7 +161,7 @@ class DokumentzyklusTest { } @Test - @DisplayName("TC-09: Storno einer offenen Rechnung -> STORNIERT, nicht mehr offen, storniertAm gesetzt") + @DisplayName("TC-09: Storno einer offenen Rechnung -> STORNIERT, nicht mehr offen, mit Datum und Benutzer protokolliert (BA-14)") void tc09Storno() { Rechnung rechnung = service.erstelleRechnung(KUNDE_NR, List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.of(2026, 6, 9), null); @@ -172,6 +172,7 @@ class DokumentzyklusTest { assertTrue(service.offeneRechnungen().stream() .noneMatch(r -> r.getBelegnummer().equals(rechnung.getBelegnummer()))); assertNotNull(storniert.getStorniertAm()); + assertEquals(StandardDokumentService.SYSTEM_BENUTZER, storniert.getStorniertVon()); } @Test diff --git a/src/test/java/de/team1/faktura/dokumente/JsonDokumentRepositoryTest.java b/src/test/java/de/team1/faktura/dokumente/JsonDokumentRepositoryTest.java new file mode 100644 index 0000000..b8f4146 --- /dev/null +++ b/src/test/java/de/team1/faktura/dokumente/JsonDokumentRepositoryTest.java @@ -0,0 +1,38 @@ +package de.team1.faktura.dokumente; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Persistenz der Belege (IF-01): gespeicherte Belege müssen nach einem + * Neustart (Neuinstanziierung des Repositories) wieder geladen werden, + * inklusive der polymorphen Belegtypen (GoBD: lückenlose Erfassung). + */ +class JsonDokumentRepositoryTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("Belege überleben den Neustart und behalten ihren Belegtyp") + void belegeWerdenNachNeustartGeladen() { + Path datei = tempDir.resolve("dokumente.json"); + + JsonDokumentRepository ersteInstanz = new JsonDokumentRepository(datei); + ersteInstanz.speichere(TestBelege.rechnung("R-2026-000001", DokumentStatus.OFFEN)); + ersteInstanz.speichere(TestBelege.angebot("AN-2026-000001", DokumentStatus.ENTWURF)); + + JsonDokumentRepository neustart = new JsonDokumentRepository(datei); + assertEquals(2, neustart.alle().size()); + assertInstanceOf(Rechnung.class, neustart.findeNachNummer("R-2026-000001")); + assertInstanceOf(Angebot.class, neustart.findeNachNummer("AN-2026-000001")); + assertNotNull(neustart.findeNachNummer("R-2026-000001").getPositionen()); + } +}