Anforderungsabgleich
parent
6b57a7b216
commit
082fee2923
|
|
@ -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.
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.team1.faktura;
|
package de.team1.faktura;
|
||||||
|
|
||||||
|
import de.team1.faktura.dokumente.DokumentCsvExport;
|
||||||
import de.team1.faktura.dokumente.DokumentReferenzPruefung;
|
import de.team1.faktura.dokumente.DokumentReferenzPruefung;
|
||||||
import de.team1.faktura.dokumente.DokumentService;
|
import de.team1.faktura.dokumente.DokumentService;
|
||||||
import de.team1.faktura.dokumente.EinfacherBelegnummernGenerator;
|
import de.team1.faktura.dokumente.EinfacherBelegnummernGenerator;
|
||||||
|
|
@ -78,7 +79,8 @@ public final class Main {
|
||||||
HauptFenster fenster = new HauptFenster(
|
HauptFenster fenster = new HauptFenster(
|
||||||
new KundenPanel(kundenService, new KundenCsvExport(kundenRepository)),
|
new KundenPanel(kundenService, new KundenCsvExport(kundenRepository)),
|
||||||
new ProduktPanel(produktService, new ProduktCsvExport(produktRepository)),
|
new ProduktPanel(produktService, new ProduktCsvExport(produktRepository)),
|
||||||
new DokumentListenPanel(dokumentService, kundenService, produktService));
|
new DokumentListenPanel(dokumentService, kundenService, produktService,
|
||||||
|
new DokumentCsvExport(dokumentRepository)));
|
||||||
fenster.setVisible(true);
|
fenster.setVisible(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,9 @@ public class JsonDokumentRepository implements DokumentRepository {
|
||||||
if (datei.getParent() != null) {
|
if (datei.getParent() != null) {
|
||||||
Files.createDirectories(datei.getParent());
|
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) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e);
|
throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public class Rechnung extends Dokument {
|
||||||
private LocalDate leistungsdatum;
|
private LocalDate leistungsdatum;
|
||||||
private LocalDate zahlungsziel;
|
private LocalDate zahlungsziel;
|
||||||
private LocalDate storniertAm;
|
private LocalDate storniertAm;
|
||||||
|
private String storniertVon;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Belegtyp belegtyp() {
|
public Belegtyp belegtyp() {
|
||||||
|
|
@ -18,10 +19,10 @@ public class Rechnung extends Dokument {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storniert eine offene Rechnung (F-19, F-20): Status wird
|
* Storniert eine offene Rechnung (BA-14, F-19, F-20): Status wird
|
||||||
* {@code STORNIERT}, der Vorgang wird mit Datum protokolliert.
|
* {@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) {
|
if (getStatus() != DokumentStatus.OFFEN) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Nur Rechnungen im Status OFFEN können storniert werden (F-19), "
|
"Nur Rechnungen im Status OFFEN können storniert werden (F-19), "
|
||||||
|
|
@ -29,10 +30,16 @@ public class Rechnung extends Dokument {
|
||||||
}
|
}
|
||||||
setzeStatus(DokumentStatus.STORNIERT);
|
setzeStatus(DokumentStatus.STORNIERT);
|
||||||
this.storniertAm = datum;
|
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() {
|
public void storniere() {
|
||||||
storniere(LocalDate.now());
|
storniere(LocalDate.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDate getLeistungsdatum() {
|
public LocalDate getLeistungsdatum() {
|
||||||
|
|
@ -56,4 +63,8 @@ public class Rechnung extends Dokument {
|
||||||
public LocalDate getStorniertAm() {
|
public LocalDate getStorniertAm() {
|
||||||
return storniertAm;
|
return storniertAm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getStorniertVon() {
|
||||||
|
return storniertVon;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ public class StandardDokumentService implements DokumentService {
|
||||||
/** Standard-Gültigkeit eines Angebots in Kalendertagen ab Erstelldatum (F-02). */
|
/** Standard-Gültigkeit eines Angebots in Kalendertagen ab Erstelldatum (F-02). */
|
||||||
public static final int STANDARD_GUELTIGKEIT_TAGE = 30;
|
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 DokumentRepository repository;
|
||||||
private final BelegnummernGenerator nummernGenerator;
|
private final BelegnummernGenerator nummernGenerator;
|
||||||
private final KundenService kundenService;
|
private final KundenService kundenService;
|
||||||
|
|
@ -164,7 +167,7 @@ public class StandardDokumentService implements DokumentService {
|
||||||
throw new ValidierungsException("Beleg",
|
throw new ValidierungsException("Beleg",
|
||||||
"Nur Rechnungen können storniert werden (F-19).");
|
"Nur Rechnungen können storniert werden (F-19).");
|
||||||
}
|
}
|
||||||
rechnung.storniere();
|
rechnung.storniere(LocalDate.now(), SYSTEM_BENUTZER);
|
||||||
repository.speichere(rechnung);
|
repository.speichere(rechnung);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,27 @@ public class DokumentListenController {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
dokumentService.storniere(rechnungsnummer);
|
dokumentService.storniere(rechnungsnummer);
|
||||||
return Meldung.erfolg("Die Rechnung " + rechnungsnummer + " wurde storniert.");
|
return Meldung.erfolg("Die Rechnung " + rechnungsnummer + " wurde storniert"
|
||||||
|
+ protokoll(rechnungsnummer) + ".");
|
||||||
} catch (ValidierungsException e) {
|
} catch (ValidierungsException e) {
|
||||||
return Meldung.fehler(e.getFeldname(), e.getMessage());
|
return Meldung.fehler(e.getFeldname(), e.getMessage());
|
||||||
} catch (IllegalStateException e) {
|
} catch (IllegalStateException e) {
|
||||||
return Meldung.fehler(null, e.getMessage());
|
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("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package de.team1.faktura.gui;
|
package de.team1.faktura.gui;
|
||||||
|
|
||||||
import de.team1.faktura.dokumente.Dokument;
|
import de.team1.faktura.dokumente.Dokument;
|
||||||
|
import de.team1.faktura.dokumente.DokumentCsvExport;
|
||||||
import de.team1.faktura.dokumente.DokumentService;
|
import de.team1.faktura.dokumente.DokumentService;
|
||||||
import de.team1.faktura.dokumente.DokumentStatus;
|
import de.team1.faktura.dokumente.DokumentStatus;
|
||||||
import de.team1.faktura.gemeinsam.ValidierungsException;
|
import de.team1.faktura.gemeinsam.ValidierungsException;
|
||||||
|
|
@ -44,6 +45,7 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
|
||||||
private final DokumentService dokumentService;
|
private final DokumentService dokumentService;
|
||||||
private final KundenService kundenService;
|
private final KundenService kundenService;
|
||||||
private final ProduktService produktService;
|
private final ProduktService produktService;
|
||||||
|
private final DokumentCsvExport datenExport;
|
||||||
private final DokumentListenController controller;
|
private final DokumentListenController controller;
|
||||||
|
|
||||||
private final JComboBox<String> statusFilter = new JComboBox<>(
|
private final JComboBox<String> statusFilter = new JComboBox<>(
|
||||||
|
|
@ -60,10 +62,12 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
|
||||||
|
|
||||||
public DokumentListenPanel(DokumentService dokumentService,
|
public DokumentListenPanel(DokumentService dokumentService,
|
||||||
KundenService kundenService,
|
KundenService kundenService,
|
||||||
ProduktService produktService) {
|
ProduktService produktService,
|
||||||
|
DokumentCsvExport datenExport) {
|
||||||
this.dokumentService = dokumentService;
|
this.dokumentService = dokumentService;
|
||||||
this.kundenService = kundenService;
|
this.kundenService = kundenService;
|
||||||
this.produktService = produktService;
|
this.produktService = produktService;
|
||||||
|
this.datenExport = datenExport;
|
||||||
this.controller = new DokumentListenController(dokumentService);
|
this.controller = new DokumentListenController(dokumentService);
|
||||||
baueOberflaeche();
|
baueOberflaeche();
|
||||||
aktualisiere();
|
aktualisiere();
|
||||||
|
|
@ -81,8 +85,11 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
|
||||||
neueRechnung.addActionListener(e -> oeffneWizard());
|
neueRechnung.addActionListener(e -> oeffneWizard());
|
||||||
JButton neuerBeleg = new JButton("Neuer Beleg…");
|
JButton neuerBeleg = new JButton("Neuer Beleg…");
|
||||||
neuerBeleg.addActionListener(e -> oeffneBelegDialog());
|
neuerBeleg.addActionListener(e -> oeffneBelegDialog());
|
||||||
|
JButton datenExportKnopf = new JButton("Daten exportieren (CSV)…");
|
||||||
|
datenExportKnopf.addActionListener(e -> exportiereDaten());
|
||||||
kopf.add(neueRechnung);
|
kopf.add(neueRechnung);
|
||||||
kopf.add(neuerBeleg);
|
kopf.add(neuerBeleg);
|
||||||
|
kopf.add(datenExportKnopf);
|
||||||
add(kopf, BorderLayout.NORTH);
|
add(kopf, BorderLayout.NORTH);
|
||||||
|
|
||||||
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
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). */
|
/** Optionaler Druck über das Betriebssystem (IF-02). */
|
||||||
private void drucke() {
|
private void drucke() {
|
||||||
Dokument dokument = auswahl();
|
Dokument dokument = auswahl();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,7 +161,7 @@ class DokumentzyklusTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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() {
|
void tc09Storno() {
|
||||||
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
|
Rechnung rechnung = service.erstelleRechnung(KUNDE_NR,
|
||||||
List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.of(2026, 6, 9), null);
|
List.of(new Positionsangabe(PRODUKT_NR, 1)), LocalDate.of(2026, 6, 9), null);
|
||||||
|
|
@ -172,6 +172,7 @@ class DokumentzyklusTest {
|
||||||
assertTrue(service.offeneRechnungen().stream()
|
assertTrue(service.offeneRechnungen().stream()
|
||||||
.noneMatch(r -> r.getBelegnummer().equals(rechnung.getBelegnummer())));
|
.noneMatch(r -> r.getBelegnummer().equals(rechnung.getBelegnummer())));
|
||||||
assertNotNull(storniert.getStorniertAm());
|
assertNotNull(storniert.getStorniertAm());
|
||||||
|
assertEquals(StandardDokumentService.SYSTEM_BENUTZER, storniert.getStorniertVon());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue