Observer Patter, Flatlaf, Persistente Daten

main
Lucas Strubel 2026-06-12 21:15:52 +02:00
parent 082fee2923
commit 26c3840d07
18 changed files with 442 additions and 79 deletions

View File

@ -44,6 +44,13 @@
<version>${pdfbox.version}</version>
</dependency>
<!-- Modernes Swing-Look-and-Feel (Q-05: Bedienbarkeit) -->
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
<version>3.6</version>
</dependency>
<!-- Modultests (JUnit 5, Kapitel 10 der Pflichtenhefte) -->
<dependency>
<groupId>org.junit.jupiter</groupId>

View File

@ -7,6 +7,7 @@ import de.team1.faktura.dokumente.EinfacherBelegnummernGenerator;
import de.team1.faktura.dokumente.JsonDokumentRepository;
import de.team1.faktura.dokumente.PdfBoxPdfExporter;
import de.team1.faktura.dokumente.StandardDokumentService;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.gui.DokumentListenPanel;
import de.team1.faktura.gui.HauptFenster;
import de.team1.faktura.gui.KundenPanel;
@ -20,6 +21,8 @@ import de.team1.faktura.produkte.JsonProduktRepository;
import de.team1.faktura.produkte.ProduktCsvExport;
import de.team1.faktura.produkte.ProduktVerwaltungsService;
import com.formdev.flatlaf.FlatLightLaf;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import java.nio.file.Path;
@ -49,17 +52,22 @@ public final class Main {
// Gruppe A stellt die Referenzprüfungen für die Löschsperren bereit
DokumentReferenzPruefung referenzPruefung = new DokumentReferenzPruefung(dokumentRepository);
// Observer-Verteiler: Services melden Datenänderungen, Panels abonnieren
EreignisBus ereignisBus = new EreignisBus();
// Gruppe C — Kundenverwaltung
KundenVerwaltungsService kundenService = new KundenVerwaltungsService(
kundenRepository,
EinfacherKundennummernGenerator.ausRepository(kundenRepository),
referenzPruefung);
referenzPruefung,
ereignisBus);
// Gruppe B — Produktverwaltung
ProduktVerwaltungsService produktService = new ProduktVerwaltungsService(
produktRepository,
EinfacherProduktnummernGenerator.ausRepository(produktRepository),
referenzPruefung);
referenzPruefung,
ereignisBus);
// Gruppe A — Dokumentenzyklus
DokumentService dokumentService = new StandardDokumentService(
@ -67,20 +75,25 @@ public final class Main {
EinfacherBelegnummernGenerator.ausRepository(dokumentRepository),
kundenService,
produktService,
new PdfBoxPdfExporter());
new PdfBoxPdfExporter(),
ereignisBus);
// Gruppe D — Programmoberfläche
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
FlatLightLaf.setup();
UIManager.put("Table.alternateRowColor", new java.awt.Color(245, 246, 248));
UIManager.put("Table.showHorizontalLines", false);
} catch (Exception e) {
// Standard-Look-and-Feel verwenden
}
HauptFenster fenster = new HauptFenster(
new KundenPanel(kundenService, new KundenCsvExport(kundenRepository)),
new ProduktPanel(produktService, new ProduktCsvExport(produktRepository)),
new KundenPanel(kundenService, new KundenCsvExport(kundenRepository),
ereignisBus),
new ProduktPanel(produktService, new ProduktCsvExport(produktRepository),
ereignisBus),
new DokumentListenPanel(dokumentService, kundenService, produktService,
new DokumentCsvExport(dokumentRepository)));
new DokumentCsvExport(dokumentRepository), ereignisBus));
fenster.setVisible(true);
});
}

View File

@ -40,12 +40,10 @@ public class JsonDokumentRepository implements DokumentRepository {
private void schreibe() {
try {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
// Ü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);
JsonPersistenz.schreibeAtomar(datei,
mapper.writerFor(new TypeReference<List<Dokument>>() { }), dokumente);
} catch (IOException e) {
throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e);
}

View File

@ -1,5 +1,7 @@
package de.team1.faktura.dokumente;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenService;
@ -32,17 +34,29 @@ public class StandardDokumentService implements DokumentService {
private final KundenService kundenService;
private final ProduktService produktService;
private final PdfExporter pdfExporter;
private final EreignisBus ereignisBus;
public StandardDokumentService(DokumentRepository repository,
BelegnummernGenerator nummernGenerator,
KundenService kundenService,
ProduktService produktService,
PdfExporter pdfExporter) {
this(repository, nummernGenerator, kundenService, produktService, pdfExporter,
new EreignisBus());
}
public StandardDokumentService(DokumentRepository repository,
BelegnummernGenerator nummernGenerator,
KundenService kundenService,
ProduktService produktService,
PdfExporter pdfExporter,
EreignisBus ereignisBus) {
this.repository = repository;
this.nummernGenerator = nummernGenerator;
this.kundenService = kundenService;
this.produktService = produktService;
this.pdfExporter = pdfExporter;
this.ereignisBus = ereignisBus;
}
@Override
@ -58,6 +72,7 @@ public class StandardDokumentService implements DokumentService {
angebot.setGueltigBis(gueltigBis != null ? gueltigBis : datum.plusDays(STANDARD_GUELTIGKEIT_TAGE));
angebot.setzePositionen(dokumentpositionen);
repository.speichere(angebot);
ereignisBus.melde(DatenBereich.DOKUMENTE);
return angebot;
}
@ -73,6 +88,7 @@ public class StandardDokumentService implements DokumentService {
ab.setzeKunde(kunde.getKundennummer(), kunde.getName(), kunde.anschrift());
ab.setzePositionen(dokumentpositionen);
repository.speichere(ab);
ereignisBus.melde(DatenBereich.DOKUMENTE);
return ab;
}
@ -89,6 +105,7 @@ public class StandardDokumentService implements DokumentService {
lieferschein.setLieferdatum(lieferdatum != null ? lieferdatum : datum);
lieferschein.setzePositionen(dokumentpositionen);
repository.speichere(lieferschein);
ereignisBus.melde(DatenBereich.DOKUMENTE);
return lieferschein;
}
@ -113,6 +130,7 @@ public class StandardDokumentService implements DokumentService {
rechnung.setzePositionen(dokumentpositionen);
rechnung.setzeStatus(DokumentStatus.OFFEN);
repository.speichere(rechnung);
ereignisBus.melde(DatenBereich.DOKUMENTE);
return rechnung;
}
@ -150,6 +168,7 @@ public class StandardDokumentService implements DokumentService {
rechnung.setzeStatus(DokumentStatus.OFFEN);
}
repository.speichere(folgebeleg);
ereignisBus.melde(DatenBereich.DOKUMENTE);
return folgebeleg;
}
@ -158,6 +177,7 @@ public class StandardDokumentService implements DokumentService {
Dokument dokument = pruefeBeleg(belegnummer);
dokument.versende();
repository.speichere(dokument);
ereignisBus.melde(DatenBereich.DOKUMENTE);
}
@Override
@ -169,6 +189,7 @@ public class StandardDokumentService implements DokumentService {
}
rechnung.storniere(LocalDate.now(), SYSTEM_BENUTZER);
repository.speichere(rechnung);
ereignisBus.melde(DatenBereich.DOKUMENTE);
}
@Override

View File

@ -0,0 +1,11 @@
package de.team1.faktura.gemeinsam;
/**
* Fachliche Datenbereiche der Anwendung, über die der {@link EreignisBus}
* Änderungen meldet.
*/
public enum DatenBereich {
KUNDEN,
PRODUKTE,
DOKUMENTE
}

View File

@ -0,0 +1,33 @@
package de.team1.faktura.gemeinsam;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Einfacher synchroner Ereignis-Verteiler (Observer-Muster): Die Services
* melden nach jeder schreibenden Operation den geänderten {@link DatenBereich},
* die Modulansichten abonnieren die für sie relevanten Bereiche und
* aktualisieren sich selbst. Dadurch entfallen manuelle Refresh-Aufrufe
* zwischen den Modulen.
*
* <p>Alle Aufrufe laufen auf dem Event-Dispatch-Thread der Swing-Oberfläche;
* eine Synchronisierung ist daher nicht erforderlich (Einzelplatzbetrieb).
*/
public class EreignisBus {
private final Map<DatenBereich, List<Runnable>> abonnenten = new EnumMap<>(DatenBereich.class);
/** Registriert einen Beobachter für Änderungen im angegebenen Bereich. */
public void abonniere(DatenBereich bereich, Runnable beobachter) {
abonnenten.computeIfAbsent(bereich, b -> new ArrayList<>()).add(beobachter);
}
/** Meldet eine Änderung im angegebenen Bereich an alle Beobachter. */
public void melde(DatenBereich bereich) {
for (Runnable beobachter : abonnenten.getOrDefault(bereich, List.of())) {
beobachter.run();
}
}
}

View File

@ -2,9 +2,16 @@ package de.team1.faktura.gemeinsam;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* Zentral konfigurierter Jackson-ObjectMapper für die lokale
* JSON-Persistenz (IF-01). Datumswerte werden als ISO-Strings
@ -23,4 +30,25 @@ public final class JsonPersistenz {
mapper.enable(SerializationFeature.INDENT_OUTPUT);
return mapper;
}
/**
* Schreibt den Bestand atomar: erst vollständig in eine temporäre Datei,
* dann per Move ersetzen. Ein Absturz während des Schreibens kann so den
* vorhandenen Bestand nicht korrumpieren (GR-01/GR-02: der Belegbestand
* ist Grundlage der lückenlosen Nummernvergabe und Unveränderlichkeit).
*/
public static void schreibeAtomar(Path datei, ObjectWriter writer, Object daten)
throws IOException {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
Path temp = datei.resolveSibling(datei.getFileName() + ".tmp");
writer.writeValue(temp.toFile(), daten);
try {
Files.move(temp, datei, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, datei, StandardCopyOption.REPLACE_EXISTING);
}
}
}

View File

@ -4,14 +4,17 @@ 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;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.kunden.KundenService;
import de.team1.faktura.produkte.ProduktService;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
@ -20,6 +23,7 @@ import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Desktop;
import java.awt.FlowLayout;
import java.awt.Window;
@ -48,8 +52,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private final DokumentCsvExport datenExport;
private final DokumentListenController controller;
private final JComboBox<String> statusFilter = new JComboBox<>(
new String[]{"Alle", "ENTWURF", "OFFEN", "VERSENDET", "STORNIERT"});
/** Statusfilter; {@code null} steht für "Alle" (F-06). */
private final JComboBox<DokumentStatus> statusFilter = new JComboBox<>(
statusFilterWerte());
private final DokumentTabellenModel tabellenModel = new DokumentTabellenModel();
private final JTable tabelle = new JTable(tabellenModel);
@ -63,7 +68,8 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
public DokumentListenPanel(DokumentService dokumentService,
KundenService kundenService,
ProduktService produktService,
DokumentCsvExport datenExport) {
DokumentCsvExport datenExport,
EreignisBus ereignisBus) {
this.dokumentService = dokumentService;
this.kundenService = kundenService;
this.produktService = produktService;
@ -71,6 +77,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
this.controller = new DokumentListenController(dokumentService);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.DOKUMENTE, this::aktualisiere);
// Kundenname und -nummer werden in der Belegliste angezeigt
ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere);
}
private void baueOberflaeche() {
@ -79,6 +88,14 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
JPanel kopf = new JPanel(new FlowLayout(FlowLayout.LEFT));
kopf.add(new JLabel("Statusfilter:"));
kopf.add(statusFilter);
statusFilter.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> liste, Object wert,
int index, boolean selektiert, boolean fokus) {
return super.getListCellRendererComponent(liste,
wert == null ? "Alle" : wert, index, selektiert, fokus);
}
});
statusFilter.addActionListener(e -> aktualisiere());
JButton neueRechnung = new JButton("Neue Rechnung (Assistent)…");
@ -94,6 +111,9 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
tabelle.getSelectionModel().addListSelectionListener(e -> aktualisiereAktionen());
TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(4).setCellRenderer(TabellenFormat.waehrungsRenderer());
tabelle.getColumnModel().getColumn(5).setCellRenderer(TabellenFormat.statusRenderer());
add(new JScrollPane(tabelle), BorderLayout.CENTER);
JPanel aktionen = new JPanel(new FlowLayout(FlowLayout.LEFT));
@ -115,7 +135,8 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
private Dokument auswahl() {
int zeile = tabelle.getSelectedRow();
return zeile < 0 ? null : tabellenModel.dokumente.get(zeile);
return zeile < 0 ? null
: tabellenModel.dokumente.get(tabelle.convertRowIndexToModel(zeile));
}
/** Aktiviert/deaktiviert die Belegaktionen gemäß Status (F-08, F-14). */
@ -144,14 +165,12 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
RechnungsWizardDialog dialog = new RechnungsWizardDialog(fenster, wizardController,
kundenService, produktService);
dialog.setVisible(true);
aktualisiere();
}
private void oeffneBelegDialog() {
Window fenster = SwingUtilities.getWindowAncestor(this);
BelegDialog dialog = new BelegDialog(fenster, dokumentService, kundenService, produktService);
dialog.setVisible(true);
aktualisiere();
}
private void erzeugeFolgebeleg() {
@ -159,15 +178,12 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
if (dokument == null) {
return;
}
try {
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
Dokument folgebeleg = dokumentService.erzeugeFolgebeleg(dokument.getBelegnummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg(folgebeleg.belegtyp().anzeigename() + " "
+ folgebeleg.getBelegnummer() + " wurde aus " + dokument.getBelegnummer()
+ " erzeugt."), null);
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), null);
}
});
}
private void versende() {
@ -182,14 +198,11 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
if (antwort != JOptionPane.YES_OPTION) {
return;
}
try {
MeldungsAnzeige.mitFehlerbehandlung(this, null, () -> {
dokumentService.versende(dokument.getBelegnummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Beleg " + dokument.getBelegnummer()
+ " ist jetzt im Status VERSENDET."), null);
} catch (IllegalStateException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), null);
}
});
}
/** Stornierung mit Bestätigungsdialog: Rechnungsnummer und Bruttosumme (F-15). */
@ -204,7 +217,6 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
"Rechnung stornieren", JOptionPane.YES_NO_OPTION);
Meldung meldung = controller.storniere(dokument.getBelegnummer(),
antwort == JOptionPane.YES_OPTION);
aktualisiere();
MeldungsAnzeige.zeige(this, meldung, null);
}
@ -277,17 +289,18 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
@Override
public void aktualisiere() {
DokumentStatus filter = switch ((String) statusFilter.getSelectedItem()) {
case "ENTWURF" -> DokumentStatus.ENTWURF;
case "OFFEN" -> DokumentStatus.OFFEN;
case "VERSENDET" -> DokumentStatus.VERSENDET;
case "STORNIERT" -> DokumentStatus.STORNIERT;
default -> null;
};
tabellenModel.setze(controller.gefiltert(filter));
tabellenModel.setze(controller.gefiltert((DokumentStatus) statusFilter.getSelectedItem()));
aktualisiereAktionen();
}
/** "Alle" (= {@code null}) gefolgt von allen Belegstatus. */
private static DokumentStatus[] statusFilterWerte() {
DokumentStatus[] status = DokumentStatus.values();
DokumentStatus[] werte = new DokumentStatus[status.length + 1];
System.arraycopy(status, 0, werte, 1, status.length);
return werte;
}
private static final class DokumentTabellenModel extends AbstractTableModel {
private static final String[] SPALTEN =
@ -315,6 +328,15 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
return SPALTEN[spalte];
}
@Override
public Class<?> getColumnClass(int spalte) {
return switch (spalte) {
case 4 -> java.math.BigDecimal.class;
case 5 -> DokumentStatus.class;
default -> String.class;
};
}
@Override
public Object getValueAt(int zeile, int spalte) {
Dokument dokument = dokumente.get(zeile);
@ -323,8 +345,8 @@ public class DokumentListenPanel extends JPanel implements ModulPanel {
case 1 -> dokument.belegtyp().anzeigename();
case 2 -> dokument.getDatum() == null ? "" : DATUM.format(dokument.getDatum());
case 3 -> dokument.getKundeName() + " (" + dokument.getKundenReferenz() + ")";
case 4 -> dokument.getSummeBrutto().toPlainString() + " EUR";
default -> dokument.getStatus().name();
case 4 -> dokument.getSummeBrutto();
default -> dokument.getStatus();
};
}
}

View File

@ -1,7 +1,7 @@
package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.kunden.Kunde;
import de.team1.faktura.kunden.KundenCsvExport;
import de.team1.faktura.kunden.KundenVerwaltungsService;
@ -56,7 +56,8 @@ public class KundenPanel extends JPanel implements ModulPanel {
private String gewaehlteNummer;
private boolean ungespeichert;
public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport) {
public KundenPanel(KundenVerwaltungsService service, KundenCsvExport csvExport,
EreignisBus ereignisBus) {
this.service = service;
this.csvExport = csvExport;
felder.put("Name", nameFeld);
@ -66,6 +67,7 @@ public class KundenPanel extends JPanel implements ModulPanel {
felder.put("E-Mail", eMailFeld);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.KUNDEN, this::aktualisiere);
}
private void baueOberflaeche() {
@ -78,6 +80,7 @@ public class KundenPanel extends JPanel implements ModulPanel {
add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle);
tabelle.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
ladeAuswahl();
@ -148,7 +151,7 @@ public class KundenPanel extends JPanel implements ModulPanel {
}
private void speichere() {
try {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
Kunde kunde = gewaehlteNummer == null
? new Kunde()
: service.findeKunde(gewaehlteNummer);
@ -166,12 +169,9 @@ public class KundenPanel extends JPanel implements ModulPanel {
ungespeichert = false;
gewaehlteNummer = gespeichert.getKundennummer();
nummerAnzeige.setText(gespeichert.getKundennummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gespeichert. Kundennummer: "
+ gespeichert.getKundennummer()), felder);
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), felder);
}
});
}
private void loesche() {
@ -184,14 +184,11 @@ public class KundenPanel extends JPanel implements ModulPanel {
if (antwort != JOptionPane.YES_OPTION) {
return;
}
try {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
service.loescheKunde(gewaehlteNummer);
leereFormular();
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Der Kunde wurde gelöscht."), felder);
} catch (LoeschAbgelehntException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), felder);
}
});
}
private void exportiere() {
@ -209,7 +206,7 @@ public class KundenPanel extends JPanel implements ModulPanel {
if (zeile < 0) {
return;
}
Kunde kunde = tabellenModel.kunden.get(zeile);
Kunde kunde = tabellenModel.kunden.get(tabelle.convertRowIndexToModel(zeile));
gewaehlteNummer = kunde.getKundennummer();
nummerAnzeige.setText(kunde.getKundennummer());
nameFeld.setText(kunde.getName());

View File

@ -1,5 +1,8 @@
package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JOptionPane;
@ -7,6 +10,7 @@ import javax.swing.UIManager;
import javax.swing.border.Border;
import java.awt.Color;
import java.awt.Component;
import java.io.UncheckedIOException;
import java.util.Map;
/**
@ -48,4 +52,24 @@ public final class MeldungsAnzeige {
JOptionPane.INFORMATION_MESSAGE);
}
}
/**
* Zentrale Fehlerbehandlung der Modulansichten: führt die Aktion aus und
* zeigt fachliche Fehler einheitlich an Validierungsfehler mit
* Feldmarkierung (Q-09), abgelehnte Löschvorgänge (GR-04), unzulässige
* Statuswechsel (GR-02) und Persistenzfehler beim Speichern (IF-01).
*/
public static void mitFehlerbehandlung(Component parent, Map<String, JComponent> felder,
Runnable aktion) {
try {
aktion.run();
} catch (ValidierungsException e) {
zeige(parent, Meldung.fehler(e.getFeldname(), e.getMessage()), felder);
} catch (LoeschAbgelehntException | IllegalStateException e) {
zeige(parent, Meldung.fehler(null, e.getMessage()), felder);
} catch (UncheckedIOException e) {
zeige(parent, Meldung.fehler(null,
"Die Daten konnten nicht gespeichert werden: " + e.getMessage()), felder);
}
}
}

View File

@ -1,6 +1,7 @@
package de.team1.faktura.gui;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.gemeinsam.ValidierungsException;
import de.team1.faktura.produkte.Produkt;
import de.team1.faktura.produkte.ProduktCsvExport;
@ -58,7 +59,8 @@ public class ProduktPanel extends JPanel implements ModulPanel {
private String gewaehlteNummer;
private boolean ungespeichert;
public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport) {
public ProduktPanel(ProduktVerwaltungsService service, ProduktCsvExport csvExport,
EreignisBus ereignisBus) {
this.service = service;
this.csvExport = csvExport;
felder.put("Bezeichnung", bezeichnungFeld);
@ -66,6 +68,7 @@ public class ProduktPanel extends JPanel implements ModulPanel {
felder.put("Steuersatz", steuersatzWahl);
baueOberflaeche();
aktualisiere();
ereignisBus.abonniere(DatenBereich.PRODUKTE, this::aktualisiere);
}
private void baueOberflaeche() {
@ -78,6 +81,8 @@ public class ProduktPanel extends JPanel implements ModulPanel {
add(suchleiste, BorderLayout.NORTH);
tabelle.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
TabellenFormat.konfiguriere(tabelle);
tabelle.getColumnModel().getColumn(2).setCellRenderer(TabellenFormat.waehrungsRenderer());
tabelle.getSelectionModel().addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) {
ladeAuswahl();
@ -145,7 +150,7 @@ public class ProduktPanel extends JPanel implements ModulPanel {
}
private void speichere() {
try {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
Produkt produkt = gewaehlteNummer == null
? new Produkt()
: service.findeProdukt(gewaehlteNummer);
@ -161,12 +166,9 @@ public class ProduktPanel extends JPanel implements ModulPanel {
ungespeichert = false;
gewaehlteNummer = gespeichert.getProduktnummer();
nummerAnzeige.setText(gespeichert.getProduktnummer());
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gespeichert. Produktnummer: "
+ gespeichert.getProduktnummer()), felder);
} catch (ValidierungsException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(e.getFeldname(), e.getMessage()), felder);
}
});
}
/** Akzeptiert deutsches und englisches Dezimaltrennzeichen. */
@ -202,14 +204,11 @@ public class ProduktPanel extends JPanel implements ModulPanel {
if (antwort != JOptionPane.YES_OPTION) {
return;
}
try {
MeldungsAnzeige.mitFehlerbehandlung(this, felder, () -> {
service.loescheProdukt(gewaehlteNummer);
leereFormular();
aktualisiere();
MeldungsAnzeige.zeige(this, Meldung.erfolg("Das Produkt wurde gelöscht."), felder);
} catch (LoeschAbgelehntException e) {
MeldungsAnzeige.zeige(this, Meldung.fehler(null, e.getMessage()), felder);
}
});
}
private void exportiere() {
@ -227,7 +226,7 @@ public class ProduktPanel extends JPanel implements ModulPanel {
if (zeile < 0) {
return;
}
Produkt produkt = tabellenModel.produkte.get(zeile);
Produkt produkt = tabellenModel.produkte.get(tabelle.convertRowIndexToModel(zeile));
gewaehlteNummer = produkt.getProduktnummer();
nummerAnzeige.setText(produkt.getProduktnummer());
bezeichnungFeld.setText(produkt.getBezeichnung());
@ -335,13 +334,18 @@ public class ProduktPanel extends JPanel implements ModulPanel {
return SPALTEN[spalte];
}
@Override
public Class<?> getColumnClass(int spalte) {
return spalte == 2 ? BigDecimal.class : String.class;
}
@Override
public Object getValueAt(int zeile, int spalte) {
Produkt produkt = produkte.get(zeile);
return switch (spalte) {
case 0 -> produkt.getProduktnummer();
case 1 -> produkt.getBezeichnung();
case 2 -> produkt.getEinzelpreisNetto().toPlainString() + " EUR";
case 2 -> produkt.getEinzelpreisNetto();
case 3 -> produkt.getSteuersatz().multiply(new BigDecimal("100"))
.stripTrailingZeros().toPlainString() + " %";
default -> produkt.getEinheit() == null ? "" : produkt.getEinheit();

View File

@ -0,0 +1,75 @@
package de.team1.faktura.gui;
import de.team1.faktura.dokumente.DokumentStatus;
import javax.swing.JLabel;
import javax.swing.JTable;
import javax.swing.table.DefaultTableCellRenderer;
import java.awt.Color;
import java.awt.Component;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.Locale;
/**
* Gemeinsame Tabellendarstellung der Modulansichten (Q-05): Währungsbeträge
* rechtsbündig im deutschen Format, Belegstatus farblich hervorgehoben,
* einheitliche Zeilenhöhe und Sortierung per Spaltenkopf.
*/
public final class TabellenFormat {
private TabellenFormat() {
}
/** Formatiert einen Betrag im deutschen Währungsformat, z. B. "1.234,50 €". */
public static String formatiereBetrag(BigDecimal betrag) {
return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(betrag);
}
/** Einheitliche Grundeinstellungen: Zeilenhöhe und Sortierung per Spaltenkopf. */
public static void konfiguriere(JTable tabelle) {
tabelle.setRowHeight(24);
tabelle.setAutoCreateRowSorter(true);
tabelle.setFillsViewportHeight(true);
}
/** Rechtsbündiger Renderer für {@link BigDecimal}-Beträge im Währungsformat. */
public static DefaultTableCellRenderer waehrungsRenderer() {
DefaultTableCellRenderer renderer = new DefaultTableCellRenderer() {
@Override
protected void setValue(Object wert) {
setText(wert instanceof BigDecimal betrag ? formatiereBetrag(betrag) : "");
}
};
renderer.setHorizontalAlignment(JLabel.RIGHT);
return renderer;
}
/** Renderer für {@link DokumentStatus}: dezente Statusfarbe, Selektion bleibt lesbar. */
public static DefaultTableCellRenderer statusRenderer() {
return new DefaultTableCellRenderer() {
@Override
public Component getTableCellRendererComponent(JTable tabelle, Object wert,
boolean selektiert, boolean fokus, int zeile, int spalte) {
super.getTableCellRendererComponent(tabelle, wert, selektiert, fokus, zeile, spalte);
if (wert instanceof DokumentStatus status) {
setText(status.name());
setForeground(selektiert ? tabelle.getSelectionForeground() : farbeFuer(status));
} else {
setForeground(selektiert ? tabelle.getSelectionForeground()
: tabelle.getForeground());
}
return this;
}
};
}
private static Color farbeFuer(DokumentStatus status) {
return switch (status) {
case ENTWURF -> new Color(110, 110, 110);
case OFFEN -> new Color(0, 90, 180);
case VERSENDET -> new Color(0, 130, 60);
case STORNIERT -> new Color(180, 40, 40);
};
}
}

View File

@ -42,10 +42,7 @@ public class JsonKundenRepository implements KundenRepository {
private void schreibe() {
try {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
mapper.writeValue(datei.toFile(), kunden);
JsonPersistenz.schreibeAtomar(datei, mapper.writer(), kunden);
} catch (IOException e) {
throw new UncheckedIOException("Kundenbestand konnte nicht gespeichert werden: " + datei, e);
}

View File

@ -1,5 +1,7 @@
package de.team1.faktura.kunden;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
@ -15,20 +17,31 @@ public class KundenVerwaltungsService implements KundenService {
private final KundenRepository repository;
private final KundennummernGenerator nummernGenerator;
private final KundenReferenzPruefung referenzPruefung;
private final EreignisBus ereignisBus;
public KundenVerwaltungsService(KundenRepository repository,
KundennummernGenerator nummernGenerator,
KundenReferenzPruefung referenzPruefung) {
this(repository, nummernGenerator, referenzPruefung, new EreignisBus());
}
public KundenVerwaltungsService(KundenRepository repository,
KundennummernGenerator nummernGenerator,
KundenReferenzPruefung referenzPruefung,
EreignisBus ereignisBus) {
this.repository = repository;
this.nummernGenerator = nummernGenerator;
this.referenzPruefung = referenzPruefung;
this.ereignisBus = ereignisBus;
}
/** Legt einen neuen Kunden an und vergibt die Kundennummer (F-01, F-02). */
public Kunde legeAn(Kunde kunde) {
validiere(kunde);
kunde.setKundennummer(nummernGenerator.naechsteNummer());
return repository.speichere(kunde);
Kunde gespeichert = repository.speichere(kunde);
ereignisBus.melde(DatenBereich.KUNDEN);
return gespeichert;
}
/** Ändert einen bestehenden Kunden; die Pflichtfeldprüfung gilt unverändert (F-05). */
@ -37,7 +50,9 @@ public class KundenVerwaltungsService implements KundenService {
throw new ValidierungsException("Kundennummer", "Der Kunde wurde noch nicht angelegt.");
}
validiere(kunde);
return repository.speichere(kunde);
Kunde gespeichert = repository.speichere(kunde);
ereignisBus.melde(DatenBereich.KUNDEN);
return gespeichert;
}
/**
@ -52,6 +67,7 @@ public class KundenVerwaltungsService implements KundenService {
+ anzahl + " verknüpfte Dokumente vorhanden (GR-04).");
}
repository.loesche(kundennummer);
ereignisBus.melde(DatenBereich.KUNDEN);
}
public List<Kunde> alleSortiertNachName() {

View File

@ -42,10 +42,7 @@ public class JsonProduktRepository implements ProduktRepository {
private void schreibe() {
try {
if (datei.getParent() != null) {
Files.createDirectories(datei.getParent());
}
mapper.writeValue(datei.toFile(), produkte);
JsonPersistenz.schreibeAtomar(datei, mapper.writer(), produkte);
} catch (IOException e) {
throw new UncheckedIOException("Produktbestand konnte nicht gespeichert werden: " + datei, e);
}

View File

@ -1,5 +1,7 @@
package de.team1.faktura.produkte;
import de.team1.faktura.gemeinsam.DatenBereich;
import de.team1.faktura.gemeinsam.EreignisBus;
import de.team1.faktura.gemeinsam.LoeschAbgelehntException;
import de.team1.faktura.gemeinsam.ValidierungsException;
@ -20,20 +22,31 @@ public class ProduktVerwaltungsService implements ProduktService {
private final ProduktRepository repository;
private final ProduktnummernGenerator nummernGenerator;
private final ProduktReferenzPruefung referenzPruefung;
private final EreignisBus ereignisBus;
public ProduktVerwaltungsService(ProduktRepository repository,
ProduktnummernGenerator nummernGenerator,
ProduktReferenzPruefung referenzPruefung) {
this(repository, nummernGenerator, referenzPruefung, new EreignisBus());
}
public ProduktVerwaltungsService(ProduktRepository repository,
ProduktnummernGenerator nummernGenerator,
ProduktReferenzPruefung referenzPruefung,
EreignisBus ereignisBus) {
this.repository = repository;
this.nummernGenerator = nummernGenerator;
this.referenzPruefung = referenzPruefung;
this.ereignisBus = ereignisBus;
}
/** Legt ein neues Produkt an und vergibt die Produktnummer (F-01, F-02). */
public Produkt legeAn(Produkt produkt) {
validiere(produkt);
produkt.setProduktnummer(nummernGenerator.naechsteNummer());
return repository.speichere(produkt);
Produkt gespeichert = repository.speichere(produkt);
ereignisBus.melde(DatenBereich.PRODUKTE);
return gespeichert;
}
/**
@ -45,7 +58,9 @@ public class ProduktVerwaltungsService implements ProduktService {
throw new ValidierungsException("Produktnummer", "Das Produkt wurde noch nicht angelegt.");
}
validiere(produkt);
return repository.speichere(produkt);
Produkt gespeichert = repository.speichere(produkt);
ereignisBus.melde(DatenBereich.PRODUKTE);
return gespeichert;
}
/**
@ -59,6 +74,7 @@ public class ProduktVerwaltungsService implements ProduktService {
+ "es wird in Dokumenten verwendet.");
}
repository.loesche(produktnummer);
ereignisBus.melde(DatenBereich.PRODUKTE);
}
public List<Produkt> alleSortiertNachBezeichnung() {

View File

@ -0,0 +1,48 @@
package de.team1.faktura.gemeinsam;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
class EreignisBusTest {
@Test
@DisplayName("melde benachrichtigt alle Beobachter des Bereichs")
void benachrichtigtAlleBeobachter() {
EreignisBus bus = new EreignisBus();
AtomicInteger ersterBeobachter = new AtomicInteger();
AtomicInteger zweiterBeobachter = new AtomicInteger();
bus.abonniere(DatenBereich.KUNDEN, ersterBeobachter::incrementAndGet);
bus.abonniere(DatenBereich.KUNDEN, zweiterBeobachter::incrementAndGet);
bus.melde(DatenBereich.KUNDEN);
bus.melde(DatenBereich.KUNDEN);
assertEquals(2, ersterBeobachter.get());
assertEquals(2, zweiterBeobachter.get());
}
@Test
@DisplayName("melde benachrichtigt nur Beobachter des betroffenen Bereichs")
void benachrichtigtNurBetroffenenBereich() {
EreignisBus bus = new EreignisBus();
AtomicInteger kundenBeobachter = new AtomicInteger();
AtomicInteger dokumentBeobachter = new AtomicInteger();
bus.abonniere(DatenBereich.KUNDEN, kundenBeobachter::incrementAndGet);
bus.abonniere(DatenBereich.DOKUMENTE, dokumentBeobachter::incrementAndGet);
bus.melde(DatenBereich.DOKUMENTE);
assertEquals(0, kundenBeobachter.get());
assertEquals(1, dokumentBeobachter.get());
}
@Test
@DisplayName("melde ohne Beobachter ist wirkungslos und wirft nicht")
void meldenOhneBeobachterIstWirkungslos() {
new EreignisBus().melde(DatenBereich.PRODUKTE);
}
}

View File

@ -0,0 +1,56 @@
package de.team1.faktura.gemeinsam;
import com.fasterxml.jackson.databind.ObjectMapper;
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.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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonPersistenzTest {
private final ObjectMapper mapper = JsonPersistenz.mapper();
@Test
@DisplayName("schreibeAtomar legt die Zieldatei an und lässt keine Temp-Datei zurück")
void schreibtOhneTempDateiRueckstand(@TempDir Path verzeichnis) throws IOException {
Path datei = verzeichnis.resolve("bestand.json");
JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("a", "b"));
assertTrue(Files.exists(datei));
assertFalse(Files.exists(verzeichnis.resolve("bestand.json.tmp")),
"Temp-Datei darf nach dem Move nicht zurückbleiben");
List<?> gelesen = mapper.readValue(datei.toFile(), List.class);
assertEquals(List.of("a", "b"), gelesen);
}
@Test
@DisplayName("schreibeAtomar ersetzt einen vorhandenen Bestand vollständig")
void ersetztVorhandenenBestand(@TempDir Path verzeichnis) throws IOException {
Path datei = verzeichnis.resolve("bestand.json");
JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("alt"));
JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("neu1", "neu2"));
List<?> gelesen = mapper.readValue(datei.toFile(), List.class);
assertEquals(List.of("neu1", "neu2"), gelesen);
}
@Test
@DisplayName("schreibeAtomar legt fehlende Elternverzeichnisse an")
void legtElternverzeichnisseAn(@TempDir Path verzeichnis) throws IOException {
Path datei = verzeichnis.resolve("unterordner").resolve("bestand.json");
JsonPersistenz.schreibeAtomar(datei, mapper.writer(), List.of("a"));
assertTrue(Files.exists(datei));
}
}