Anforderungsabgleich

main
Lucas Strubel 2026-06-10 18:02:08 +02:00
parent 6b57a7b216
commit 082fee2923
12 changed files with 470 additions and 10 deletions

View File

@ -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.

View File

@ -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);
});
}

View File

@ -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<String> 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();
}
}

View File

@ -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<List<Dokument>>() { }).writeValue(datei.toFile(), dokumente);
} catch (IOException e) {
throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e);
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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("");
}
}

View File

@ -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<String> 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();

View File

@ -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.
*
* <p>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<Kunde> kunden = new ArrayList<>(ANZAHL_STAMMDATEN);
List<Produkt> 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<Dokument> 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<List<Dokument>>() { }).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<Dokumentposition> 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;
}
}

View File

@ -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<String> 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")));
}
}

View File

@ -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

View File

@ -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());
}
}