>() { }), dokumente);
} catch (IOException e) {
throw new UncheckedIOException("Belegbestand konnte nicht gespeichert werden: " + datei, e);
}
diff --git a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java
index b083110..a4bc7cd 100644
--- a/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java
+++ b/src/main/java/de/team1/faktura/dokumente/StandardDokumentService.java
@@ -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
diff --git a/src/main/java/de/team1/faktura/gemeinsam/DatenBereich.java b/src/main/java/de/team1/faktura/gemeinsam/DatenBereich.java
new file mode 100644
index 0000000..7bc60d9
--- /dev/null
+++ b/src/main/java/de/team1/faktura/gemeinsam/DatenBereich.java
@@ -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
+}
diff --git a/src/main/java/de/team1/faktura/gemeinsam/EreignisBus.java b/src/main/java/de/team1/faktura/gemeinsam/EreignisBus.java
new file mode 100644
index 0000000..10bdb3c
--- /dev/null
+++ b/src/main/java/de/team1/faktura/gemeinsam/EreignisBus.java
@@ -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.
+ *
+ * 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> 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();
+ }
+ }
+}
diff --git a/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java b/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java
index 4467521..dfc40c2 100644
--- a/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java
+++ b/src/main/java/de/team1/faktura/gemeinsam/JsonPersistenz.java
@@ -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);
+ }
+ }
}
diff --git a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java
index 698af1d..945ad00 100644
--- a/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java
+++ b/src/main/java/de/team1/faktura/gui/DokumentListenPanel.java
@@ -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 statusFilter = new JComboBox<>(
- new String[]{"Alle", "ENTWURF", "OFFEN", "VERSENDET", "STORNIERT"});
+ /** Statusfilter; {@code null} steht für "Alle" (F-06). */
+ private final JComboBox 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();
};
}
}
diff --git a/src/main/java/de/team1/faktura/gui/KundenPanel.java b/src/main/java/de/team1/faktura/gui/KundenPanel.java
index f948bc0..cd470a3 100644
--- a/src/main/java/de/team1/faktura/gui/KundenPanel.java
+++ b/src/main/java/de/team1/faktura/gui/KundenPanel.java
@@ -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());
diff --git a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java
index 89a402b..3131c0b 100644
--- a/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java
+++ b/src/main/java/de/team1/faktura/gui/MeldungsAnzeige.java
@@ -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 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);
+ }
+ }
}
diff --git a/src/main/java/de/team1/faktura/gui/ProduktPanel.java b/src/main/java/de/team1/faktura/gui/ProduktPanel.java
index 3d4bc8c..cc93aed 100644
--- a/src/main/java/de/team1/faktura/gui/ProduktPanel.java
+++ b/src/main/java/de/team1/faktura/gui/ProduktPanel.java
@@ -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();
diff --git a/src/main/java/de/team1/faktura/gui/TabellenFormat.java b/src/main/java/de/team1/faktura/gui/TabellenFormat.java
new file mode 100644
index 0000000..6b8917f
--- /dev/null
+++ b/src/main/java/de/team1/faktura/gui/TabellenFormat.java
@@ -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);
+ };
+ }
+}
diff --git a/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java b/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java
index ae346f6..bd481c2 100644
--- a/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java
+++ b/src/main/java/de/team1/faktura/kunden/JsonKundenRepository.java
@@ -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);
}
diff --git a/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java b/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java
index c8a012b..b9d062e 100644
--- a/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java
+++ b/src/main/java/de/team1/faktura/kunden/KundenVerwaltungsService.java
@@ -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 alleSortiertNachName() {
diff --git a/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java b/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java
index b8f7b3f..7106cb9 100644
--- a/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java
+++ b/src/main/java/de/team1/faktura/produkte/JsonProduktRepository.java
@@ -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);
}
diff --git a/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java b/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java
index 9eeb4de..68f8acc 100644
--- a/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java
+++ b/src/main/java/de/team1/faktura/produkte/ProduktVerwaltungsService.java
@@ -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 alleSortiertNachBezeichnung() {
diff --git a/src/test/java/de/team1/faktura/gemeinsam/EreignisBusTest.java b/src/test/java/de/team1/faktura/gemeinsam/EreignisBusTest.java
new file mode 100644
index 0000000..4eb774f
--- /dev/null
+++ b/src/test/java/de/team1/faktura/gemeinsam/EreignisBusTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/de/team1/faktura/gemeinsam/JsonPersistenzTest.java b/src/test/java/de/team1/faktura/gemeinsam/JsonPersistenzTest.java
new file mode 100644
index 0000000..fb30385
--- /dev/null
+++ b/src/test/java/de/team1/faktura/gemeinsam/JsonPersistenzTest.java
@@ -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));
+ }
+}