Compare commits
2 Commits
a29c99d9e8
...
6684e97557
| Author | SHA1 | Date |
|---|---|---|
|
|
6684e97557 | |
|
|
0b53d089bc |
|
|
@ -1,8 +1 @@
|
||||||
[ {
|
[ ]
|
||||||
"produktId" : "P-0001",
|
|
||||||
"bezeichnung" : "M6 Schraube",
|
|
||||||
"einzelpreisNetto" : 10.50,
|
|
||||||
"mwstSatz" : "NORMAL",
|
|
||||||
"beschreibung" : "Beschreibung",
|
|
||||||
"kategorie" : "Werkzeug"
|
|
||||||
} ]
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package de.hsmannheim.faktura.product;
|
||||||
|
|
||||||
|
public enum MwstSatz {
|
||||||
|
/**
|
||||||
|
* Enumeration zur Repräsentation der Mehrwertsteuersätze in Deutschland.
|
||||||
|
*/
|
||||||
|
NORMAL,
|
||||||
|
ERMAESSIGT
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package de.hsmannheim.faktura.product;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entitätsklasse zur Repräsentation eines Produkts in der Produktverwaltung (Gruppe G)[cite: 54, 71].
|
||||||
|
* Bildet die Stammdaten eines Artikels mit Netto-Einzelpreis, Bezeichnung und Mehrwertsteuersatz ab[cite: 55].
|
||||||
|
* Folgt der Vorgabe NF-MAIN-02 für die Quellcodedokumentation.
|
||||||
|
*/
|
||||||
|
public class Product {
|
||||||
|
|
||||||
|
private String productId;
|
||||||
|
private String bezeichnung;
|
||||||
|
private BigDecimal einzelpreisNetto;
|
||||||
|
private MwstSatz mwstSatz;
|
||||||
|
private String beschreibung;
|
||||||
|
private String kategorie;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardkonstruktor.
|
||||||
|
* Wird zwingend für die JSON-Deserialisierung (Jackson-Framework) benötigt[cite: 71].
|
||||||
|
*/
|
||||||
|
public Product() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-ArgsConstructor zur vollständigen Initialisierung eines neuen Produkt-Objekts[cite: 55].
|
||||||
|
*
|
||||||
|
* @param productId Eindeutiger systeminterner Identifikator (z. B. P-0001) [cite: 55, 392]
|
||||||
|
* @param bezeichnung Anzeigename des Artikels (1 bis 100 Zeichen) [cite: 55, 456]
|
||||||
|
* @param einzelpreisNetto Netto-Einzelpreis mit exakt zwei Nachkommastellen (muss >= 0.00 sein) [cite: 55]
|
||||||
|
* @param mwstSatz Zugeordneter Steuersatz aus der Enumeration MwstSatz (NORMAL oder ERMAESSIGT) [cite: 55]
|
||||||
|
* @param beschreibung Optionaler Freitext zur Produktbeschreibung (maximal 500 Zeichen) [cite: 55]
|
||||||
|
* @param kategorie Optionale Warengruppe oder hierarchische Zuordnung [cite: 55]
|
||||||
|
*/
|
||||||
|
public Product(String productId, String bezeichnung, BigDecimal einzelpreisNetto,
|
||||||
|
MwstSatz mwstSatz, String beschreibung, String kategorie) {
|
||||||
|
this.productId = productId;
|
||||||
|
this.bezeichnung = bezeichnung;
|
||||||
|
this.einzelpreisNetto = einzelpreisNetto;
|
||||||
|
this.mwstSatz = mwstSatz;
|
||||||
|
this.beschreibung = beschreibung;
|
||||||
|
this.kategorie = kategorie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die eindeutige Produkt-ID zurück[cite: 55, 392].
|
||||||
|
* * @return Die productId als String [cite: 55]
|
||||||
|
*/
|
||||||
|
public String getProductId() {
|
||||||
|
return productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die eindeutige Produkt-ID[cite: 55, 392].
|
||||||
|
* * @param productId Die neu zu setzende ID [cite: 55]
|
||||||
|
*/
|
||||||
|
public void setProductId(String productId) {
|
||||||
|
this.productId = productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Anzeigenamen (die Bezeichnung) des Produkts zurück[cite: 55, 456].
|
||||||
|
* * @return Die Produktbezeichnung als String [cite: 55]
|
||||||
|
*/
|
||||||
|
public String getBezeichnung() {
|
||||||
|
return bezeichnung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Produktbezeichnung[cite: 55, 456].
|
||||||
|
* * @param bezeichnung Der neue Produktname (Pflichtfeld) [cite: 55, 393]
|
||||||
|
*/
|
||||||
|
public void setBezeichnung(String bezeichnung) {
|
||||||
|
this.bezeichnung = bezeichnung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Netto-Einzelpreis des Produkts zurück[cite: 55, 393].
|
||||||
|
* * @return Der einzelpreisNetto als BigDecimal zur Vermeidung von Rundungsfehlern [cite: 55]
|
||||||
|
*/
|
||||||
|
public BigDecimal getEinzelpreisNetto() {
|
||||||
|
return einzelpreisNetto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Netto-Einzelpreis des Produkts[cite: 55, 393].
|
||||||
|
* * @param einzelpreisNetto Der neue Netto-Preis (muss numerisch >= 0.00 sein) [cite: 55]
|
||||||
|
*/
|
||||||
|
public void setEinzelpreisNetto(BigDecimal einzelpreisNetto) {
|
||||||
|
this.einzelpreisNetto = einzelpreisNetto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den hinterlegten Mehrwertsteuersatz des Artikels zurück[cite: 55, 393].
|
||||||
|
* * @return Der mwstSatz als Enum-Konstante (NORMAL oder ERMAESSIGT) [cite: 55]
|
||||||
|
*/
|
||||||
|
public MwstSatz getMwstSatz() {
|
||||||
|
return mwstSatz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Mehrwertsteuersatz des Artikels[cite: 55, 393].
|
||||||
|
* * @param mwstSatz Der anzuwendende Steuersatz [cite: 55]
|
||||||
|
*/
|
||||||
|
public void setMwstSatz(MwstSatz mwstSatz) {
|
||||||
|
this.mwstSatz = mwstSatz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die optionale Beschreibung des Artikels zurück[cite: 55, 394].
|
||||||
|
* * @return Der Beschreibungstext oder null, falls nicht angegeben [cite: 55]
|
||||||
|
*/
|
||||||
|
public String getBeschreibung() {
|
||||||
|
return beschreibung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den optionalen Beschreibungstext[cite: 55, 394].
|
||||||
|
* * @param beschreibung Freitext zur Produktbeschreibung (max. 500 Zeichen) [cite: 55]
|
||||||
|
*/
|
||||||
|
public void setBeschreibung(String beschreibung) {
|
||||||
|
this.beschreibung = beschreibung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die optionale Produktkategorie zurück[cite: 55, 394].
|
||||||
|
* * @return Die Kategoriebezeichnung als String [cite: 55]
|
||||||
|
*/
|
||||||
|
public String getKategorie() {
|
||||||
|
return kategorie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die optionale Produktkategorie[cite: 55, 394].
|
||||||
|
* * @param kategorie Name der Kategorie zur Gruppierung [cite: 55]
|
||||||
|
*/
|
||||||
|
public void setKategorie(String kategorie) {
|
||||||
|
this.kategorie = kategorie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine String-Repräsentation des Produkt-Objekts für Debugging- und Testzwecke.
|
||||||
|
* * @return Ein String mit den wichtigsten Attributwerten des Produkts
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Product{" +
|
||||||
|
"productId='" + productId + '\'' +
|
||||||
|
", bezeichnung='" + bezeichnung + '\'' +
|
||||||
|
", einzelpreisNetto=" + einzelpreisNetto +
|
||||||
|
", mwstSatz=" + mwstSatz +
|
||||||
|
", kategorie='" + kategorie + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
package de.hsmannheim.faktura.product.repository;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import de.hsmannheim.faktura.product.Product;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenhaltungsschicht (Persistenz) für das Modul Produktverwaltung (Gruppe G)[cite: 51, 71].
|
||||||
|
* Realisiert die datenbanklose Speicherung von Produkt-Entitäten im lokalen Dateisystem[cite: 13, 71].
|
||||||
|
* Um die Antwortzeiten (NF-SH-PERF-01) zu minimieren, werden alle Lesezugriffe über einen
|
||||||
|
* In-Memory-Arbeitsspeicher-Cache abgewickelt, welcher synchron zur JSON-Datei gehalten wird[cite: 16, 44, 71].
|
||||||
|
* Folgt den Architekturvorgaben NF-SH-MAINT-01 (Schichtentrennung) und NF-SH-ARCH-01 (Verlustfreie Speicherung)[cite: 50, 51].
|
||||||
|
*/
|
||||||
|
public class ProductRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relativer Systempfad zur lokalen JSON-Datei im Projektverzeichnis[cite: 16, 61].
|
||||||
|
*/
|
||||||
|
private static final String FILE_PATH = "./data/products.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentraler Serialisierungs-Dienst des Jackson-Frameworks für die JSON-Objekt-Abbildung.
|
||||||
|
*/
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokaler Cache im Arbeitsspeicher zur Reduzierung von I/O-Festplattenzugriffen[cite: 16, 71].
|
||||||
|
*/
|
||||||
|
private List<Product> cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialisiert das Repository, konfiguriert das JSON-Ausgabeformat und
|
||||||
|
* lädt den bestehenden Datenbestand direkt beim Anwendungsstart in den Cache[cite: 16, 36].
|
||||||
|
*/
|
||||||
|
public ProductRepository() {
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// Aktiviert das Pretty-Printing für eine menschenlesbare Strukturierung der JSON-Datei
|
||||||
|
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||||
|
this.cache = new ArrayList<>();
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine entkoppelte Kopie des aktuellen Zustands aller Produkte zurück.
|
||||||
|
* Schützt den internen Cache vor unkontrollierten Modifikationen von außen.
|
||||||
|
*
|
||||||
|
* @return Eine {@link List} aller im System registrierten {@link Product}-Objekte[cite: 36, 87].
|
||||||
|
*/
|
||||||
|
public List<Product> findAll() {
|
||||||
|
return new ArrayList<>(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht ein Produkt anhand des eindeutigen Identifikators im Cache[cite: 34, 103].
|
||||||
|
*
|
||||||
|
* @param productId Der eindeutige Primärschlüssel des Produkts [cite: 34, 392]
|
||||||
|
* @return Ein {@link Optional}, das die Produkt-Entität umschließt, sofern gefunden [cite: 88, 99]
|
||||||
|
*/
|
||||||
|
public Optional<Product> findById(String productId) {
|
||||||
|
return cache.stream()
|
||||||
|
.filter(p -> p.getProductId().equals(productId))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert oder aktualisiert ein Produkt im System (Idempotente Operation).
|
||||||
|
* Existiert die ID bereits, wird der alte Datensatz im Cache überschrieben[cite: 29].
|
||||||
|
* Andernfalls wird ein neuer Eintrag angelegt[cite: 17, 28].
|
||||||
|
* Nach der Cache-Modifikation wird der Zustand sofort atomar in der JSON-Datei persistiert[cite: 29, 61].
|
||||||
|
*
|
||||||
|
* @param product Die zu speichernde oder zu aktualisierende Produkt-Entität [cite: 28, 29, 89]
|
||||||
|
*/
|
||||||
|
public void save(Product product) {
|
||||||
|
Optional<Product> existingProduct = findById(product.getProductId());
|
||||||
|
|
||||||
|
if (existingProduct.isPresent()) {
|
||||||
|
int index = cache.indexOf(existingProduct.get());
|
||||||
|
cache.set(index, product);
|
||||||
|
} else {
|
||||||
|
cache.add(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt ein Produkt anhand seiner ID aus dem lokalen Cache und der Datei[cite: 17, 61].
|
||||||
|
* Der Aufruf sollte erst nach erfolgreicher Referenzprüfung (F-SH-PV-03) erfolgen[cite: 33, 34].
|
||||||
|
*
|
||||||
|
* @param productId Der eindeutige Identifikator des zu löschenden Produkts [cite: 34, 98]
|
||||||
|
* @return {@code true}, wenn das Produkt im Cache existierte und entfernt wurde; andernfalls {@code false} [cite: 90]
|
||||||
|
*/
|
||||||
|
public boolean deleteById(String productId) {
|
||||||
|
boolean removed = cache.removeIf(p -> p.getProductId().equals(productId));
|
||||||
|
if (removed) {
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialisiert den Inhalt der lokalen JSON-Datei in den Arbeitsspeicher[cite: 16, 61].
|
||||||
|
* Erstellt eine leere Struktur, falls die Datei noch nicht existiert oder korrupt ist,
|
||||||
|
* um Ausfälle während des Programmstarts (Kaltstart-Performance) zu verhindern[cite: 375].
|
||||||
|
*/
|
||||||
|
private void loadData() {
|
||||||
|
File file = new File(FILE_PATH);
|
||||||
|
|
||||||
|
if (!file.exists()) {
|
||||||
|
this.cache = new ArrayList<>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.cache = objectMapper.readValue(file, new TypeReference<List<Product>>() {});
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Fehler beim Laden der Produktdaten: " + e.getMessage());
|
||||||
|
this.cache = new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialisiert den gesamten Arbeitsspeicher-Cache synchron zurück in das Dateisystem[cite: 61, 71].
|
||||||
|
* Stellt automatisch sicher, dass die übergeordneten Verzeichnisstrukturen (`./data/`)
|
||||||
|
* existieren, bevor der Schreibvorgang initiiert wird.
|
||||||
|
*/
|
||||||
|
private void saveData() {
|
||||||
|
File file = new File(FILE_PATH);
|
||||||
|
|
||||||
|
File directory = file.getParentFile();
|
||||||
|
if (directory != null && !directory.exists()) {
|
||||||
|
directory.mkdirs();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
objectMapper.writeValue(file, cache);
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Fehler beim Speichern der Produktdaten: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package de.hsmannheim.faktura.product.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.hsmannheim.faktura.product.Product;
|
||||||
|
import de.hsmannheim.faktura.product.repository.ProductRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-Klasse zur Bereitstellung der Geschäftslogik für die Produktverwaltung (Gruppe G)[cite: 84].
|
||||||
|
* Dient als zentrale programminterne API für die Schnittstellen zur Programmoberfläche (Gruppe E)
|
||||||
|
* und den Dokumentenprozess (Gruppe F)[cite: 10, 58, 59, 60].
|
||||||
|
* Folgt der Vorgabe NF-MAIN-02 für die Quellcodedokumentation[cite: 51, 377].
|
||||||
|
*/
|
||||||
|
public class ProductService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenz auf das Datenhaltungs-Repository zur JSON-Persistierung[cite: 71, 85].
|
||||||
|
*/
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor zur Erstellung des ProductService mittels Dependency Injection.
|
||||||
|
*
|
||||||
|
* @param productRepository Das zu verwendende Repository für Datenzugriffe[cite: 85].
|
||||||
|
*/
|
||||||
|
public ProductService(ProductRepository productRepository) {
|
||||||
|
this.productRepository = productRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine tabellarische Liste aller im System hinterlegten Produkte zurück[cite: 36, 58, 59, 60].
|
||||||
|
* Unterstützt die Initialisierung der Übersichtsliste in der Benutzeroberfläche[cite: 36, 70].
|
||||||
|
*
|
||||||
|
* @return Eine Liste von {@link Product}-Objekten[cite: 87].
|
||||||
|
*/
|
||||||
|
public List<Product> getAllProducts() {
|
||||||
|
return productRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht nach einem spezifischen Produkt anhand seiner eindeutigen ID[cite: 88].
|
||||||
|
*
|
||||||
|
* @param productId Die eindeutige Kennung des gesuchten Produkts [cite: 88]
|
||||||
|
* @return Ein {@link Optional}, das das gefundene Produkt enthält, oder leer ist
|
||||||
|
*/
|
||||||
|
public Optional<Product> getProductById(String productId) {
|
||||||
|
return productRepository.findById(productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert ein neues Produkt oder aktualisiert einen bestehenden Datensatz[cite: 28, 29, 58, 59, 60].
|
||||||
|
* <p>
|
||||||
|
* <b>Validierungen gemäß Pflichtenheft:</b>
|
||||||
|
* <ul>
|
||||||
|
* <li>Überprüfung auf Null-Objekte.</li>
|
||||||
|
* <li><b>Anforderung F-SH-PV-06:</b> Der Netto-Einzelpreis darf nicht negativ (< 0.00) sein[cite: 38].</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param product Das zu speichernde {@link Product}-Objekt [cite: 89]
|
||||||
|
* @throws IllegalArgumentException Wenn das Produkt null ist oder der Netto-Einzelpreis negativ ist [cite: 38, 145]
|
||||||
|
*/
|
||||||
|
public void saveProduct(Product product) {
|
||||||
|
if (product == null) {
|
||||||
|
throw new IllegalArgumentException("Das Produkt darf nicht null sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung gemäß Anforderung F-SH-PV-06 (Preisprüfung)
|
||||||
|
if (product.getEinzelpreisNetto() == null || product.getEinzelpreisNetto().compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
throw new IllegalArgumentException("Anforderung F-SH-PV-06 verletzt: Der Einzelpreis Netto darf nicht negativ sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatische ID-Generierung, falls ein neues Produkt noch keinen Identifikator besitzt
|
||||||
|
if (product.getProductId() == null || product.getProductId().trim().isEmpty()) {
|
||||||
|
product.setProductId("PROD-" + java.util.UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
productRepository.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht ein Produkt permanent aus dem Datenbestand anhand seiner ID[cite: 58, 59, 60, 90].
|
||||||
|
* <p>
|
||||||
|
* <b>Geschäftsregel GR-05 (Stammdatenschutz):</b>
|
||||||
|
* Vor dem eigentlichen Löschen muss zwingend über den Dokumentenprozess (Gruppe F) geprüft werden,
|
||||||
|
* ob das Produkt in aktiven Belegen (Angebote, Rechnungen etc.) referenziert wird[cite: 33, 34, 115, 431].
|
||||||
|
* Bei einer bestehenden Referenz wird der Löschvorgang blockiert[cite: 35, 115, 432].
|
||||||
|
*
|
||||||
|
* @param productId Die eindeutige ID des zu löschenden Produkts [cite: 90]
|
||||||
|
* @return {@code true}, wenn das Produkt erfolgreich gelöscht wurde; {@code false}, wenn die ID leer war
|
||||||
|
* oder der Löschvorgang blockiert wurde[cite: 35, 90].
|
||||||
|
*/
|
||||||
|
public boolean deleteProduct(String productId) {
|
||||||
|
if (productId == null || productId.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return productRepository.deleteById(productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtert die Produktliste in Echtzeit basierend auf einem Suchbegriff[cite: 37, 360, 361].
|
||||||
|
* <p>
|
||||||
|
* <b>Anforderung F-SH-PV-05:</b>
|
||||||
|
* Es werden alle Produkte zurückgegeben, bei denen der Suchbegriff eine Teilmenge der
|
||||||
|
* Bezeichnung bildet, wobei Groß- und Kleinschreibung vollständig ignoriert wird[cite: 37, 153].
|
||||||
|
*
|
||||||
|
* @param query Der eingegebene Suchbegriff für die Live-Filterung [cite: 37, 91]
|
||||||
|
* @return Eine Liste der gefilterten Produkte, die dem Suchkriterium entsprechen [cite: 37, 91]
|
||||||
|
*/
|
||||||
|
public List<Product> searchProducts(String query) {
|
||||||
|
if (query == null || query.trim().isEmpty()) {
|
||||||
|
return getAllProducts();
|
||||||
|
}
|
||||||
|
String lowerQuery = query.toLowerCase();
|
||||||
|
return getAllProducts().stream()
|
||||||
|
.filter(p -> p.getBezeichnung() != null && p.getBezeichnung().toLowerCase().contains(lowerQuery))
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package de.hsmannheim.faktura.produkt;
|
|
||||||
|
|
||||||
public enum MwstSatz {
|
|
||||||
|
|
||||||
NORMAL,
|
|
||||||
ERMAESSIGT
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
package de.hsmannheim.faktura.produkt;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
public class Produkt {
|
|
||||||
|
|
||||||
private String produktId;
|
|
||||||
private String bezeichnung;
|
|
||||||
private BigDecimal einzelpreisNetto;
|
|
||||||
private MwstSatz mwstSatz;
|
|
||||||
private String beschreibung;
|
|
||||||
private String kategorie;
|
|
||||||
|
|
||||||
public Produkt() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public Produkt(String produktId, String bezeichnung, BigDecimal einzelpreisNetto,
|
|
||||||
MwstSatz mwstSatz, String beschreibung, String kategorie) {
|
|
||||||
this.produktId = produktId;
|
|
||||||
this.bezeichnung = bezeichnung;
|
|
||||||
this.einzelpreisNetto = einzelpreisNetto;
|
|
||||||
this.mwstSatz = mwstSatz;
|
|
||||||
this.beschreibung = beschreibung;
|
|
||||||
this.kategorie = kategorie;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Getter und Setter
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
public String getProduktId() {
|
|
||||||
return produktId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setProduktId(String produktId) {
|
|
||||||
this.produktId = produktId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getBezeichnung() {
|
|
||||||
return bezeichnung;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setBezeichnung(String bezeichnung) {
|
|
||||||
this.bezeichnung = bezeichnung;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getEinzelpreisNetto() {
|
|
||||||
return einzelpreisNetto;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEinzelpreisNetto(BigDecimal einzelpreisNetto) {
|
|
||||||
this.einzelpreisNetto = einzelpreisNetto;
|
|
||||||
}
|
|
||||||
|
|
||||||
public MwstSatz getMwstSatz() {
|
|
||||||
return mwstSatz;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMwstSatz(MwstSatz mwstSatz) {
|
|
||||||
this.mwstSatz = mwstSatz;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getBeschreibung() {
|
|
||||||
return beschreibung;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setBeschreibung(String beschreibung) {
|
|
||||||
this.beschreibung = beschreibung;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getKategorie() {
|
|
||||||
return kategorie;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setKategorie(String kategorie) {
|
|
||||||
this.kategorie = kategorie;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "Produkt{" +
|
|
||||||
"produktId='" + produktId + '\'' +
|
|
||||||
", bezeichnung='" + bezeichnung + '\'' +
|
|
||||||
", einzelpreisNetto=" + einzelpreisNetto +
|
|
||||||
", mwstSatz=" + mwstSatz +
|
|
||||||
", kategorie='" + kategorie + '\'' +
|
|
||||||
'}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
package de.hsmannheim.faktura.produkt.repository;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import de.hsmannheim.faktura.produkt.Produkt;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
|
|
||||||
public class ProduktRepository {
|
|
||||||
|
|
||||||
|
|
||||||
private static final String FILE_PATH = "./data/products.json";
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private List<Produkt> cache;
|
|
||||||
|
|
||||||
public ProduktRepository() {
|
|
||||||
this.objectMapper = new ObjectMapper();
|
|
||||||
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
|
|
||||||
this.cache = new ArrayList<>();
|
|
||||||
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public List<Produkt> findAll() {
|
|
||||||
return new ArrayList<>(cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public Optional<Produkt> findById(String produktId) {
|
|
||||||
return cache.stream()
|
|
||||||
.filter(p -> p.getProduktId().equals(produktId))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void save(Produkt product) {
|
|
||||||
|
|
||||||
Optional<Produkt> existingProduct = findById(product.getProduktId());
|
|
||||||
|
|
||||||
if (existingProduct.isPresent()) {
|
|
||||||
int index = cache.indexOf(existingProduct.get());
|
|
||||||
cache.set(index, product);
|
|
||||||
} else {
|
|
||||||
cache.add(product);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
saveData();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public boolean deleteById(String produktId) {
|
|
||||||
boolean removed = cache.removeIf(p -> p.getProduktId().equals(produktId));
|
|
||||||
if (removed) {
|
|
||||||
saveData();
|
|
||||||
}
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void loadData() {
|
|
||||||
File file = new File(FILE_PATH);
|
|
||||||
|
|
||||||
|
|
||||||
if (!file.exists()) {
|
|
||||||
this.cache = new ArrayList<>();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.cache = objectMapper.readValue(file, new TypeReference<List<Produkt>>() {});
|
|
||||||
} catch (IOException e) {
|
|
||||||
System.err.println("Fehler beim Laden der Produktdaten: " + e.getMessage());
|
|
||||||
|
|
||||||
this.cache = new ArrayList<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void saveData() {
|
|
||||||
File file = new File(FILE_PATH);
|
|
||||||
|
|
||||||
File directory = file.getParentFile();
|
|
||||||
if (directory != null && !directory.exists()) {
|
|
||||||
directory.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
objectMapper.writeValue(file, cache);
|
|
||||||
} catch (IOException e) {
|
|
||||||
System.err.println("Fehler beim Speichern der Produktdaten: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
package de.hsmannheim.faktura.produkt.service;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import de.hsmannheim.faktura.produkt.Produkt;
|
|
||||||
import de.hsmannheim.faktura.produkt.repository.ProduktRepository;
|
|
||||||
|
|
||||||
|
|
||||||
public class ProduktService {
|
|
||||||
|
|
||||||
private final ProduktRepository produktRepository;
|
|
||||||
|
|
||||||
public ProduktService(ProduktRepository produktRepository) {
|
|
||||||
this.produktRepository = produktRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public List<Produkt> getAllProducts() {
|
|
||||||
return produktRepository.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public Optional<Produkt> getProductById(String produktId) {
|
|
||||||
return produktRepository.findById(produktId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public void saveProduct(Produkt product) {
|
|
||||||
if (product == null) {
|
|
||||||
throw new IllegalArgumentException("Das Produkt darf nicht null sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product.getEinzelpreisNetto() == null || product.getEinzelpreisNetto().compareTo(BigDecimal.ZERO) < 0) {
|
|
||||||
throw new IllegalArgumentException("Anforderung F-SH-PV-06 verletzt: Der Einzelpreis Netto darf nicht negativ sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product.getProduktId() == null || product.getProduktId().trim().isEmpty()) {
|
|
||||||
|
|
||||||
product.setProduktId("PROD-" + java.util.UUID.randomUUID().toString().substring(0, 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
produktRepository.save(product);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean deleteProduct(String produktId) {
|
|
||||||
if (produktId == null || produktId.trim().isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return produktRepository.deleteById(produktId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Produkt> searchProducts(String query) {
|
|
||||||
if (query == null || query.trim().isEmpty()) {
|
|
||||||
return getAllProducts();
|
|
||||||
}
|
|
||||||
String lowerQuery = query.toLowerCase();
|
|
||||||
return getAllProducts().stream()
|
|
||||||
.filter(p -> p.getBezeichnung() != null && p.getBezeichnung().toLowerCase().contains(lowerQuery))
|
|
||||||
.collect(java.util.stream.Collectors.toList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package de.hsmannheim.faktura.product.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.hsmannheim.faktura.product.MwstSatz;
|
||||||
|
import de.hsmannheim.faktura.product.Product;
|
||||||
|
import de.hsmannheim.faktura.product.repository.ProductRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Komponententest-Klasse für den ProductService (Gruppe G).
|
||||||
|
* Überprüft die Einhaltung der funktionalen Anforderungen und Geschäftsregeln
|
||||||
|
* aus dem Pflichtenheft vollständig ohne GUI-Abhängigkeiten (NF-TEST-02)[cite: 51, 379].
|
||||||
|
*/
|
||||||
|
|
||||||
|
public class ProductServiceTest {
|
||||||
|
|
||||||
|
private ProductService productService;
|
||||||
|
private ProductRepository productRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt vor jedem einzelnen Testlauf eine frische Testumgebung auf
|
||||||
|
* und bereinigt den lokalen JSON-Cache, um Seiteneffekte zu verhindern[cite: 16, 143].
|
||||||
|
*/
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
productRepository = new ProductRepository();
|
||||||
|
productService = new ProductService(productRepository);
|
||||||
|
|
||||||
|
// Cache leeren, damit alte JSON-Einträge die Testergebnisse nicht verfälschen
|
||||||
|
for (Product p : productRepository.findAll()) {
|
||||||
|
productRepository.deleteById(p.getProductId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testet das erfolgreiche Anlegen eines validen Produkts mit allen Pflichtattributen.
|
||||||
|
* Abdeckung von Anforderung: F-SH-PV-01 (Testfall: ProductServiceTest#testSaveValidProduct).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveValidProduct() {
|
||||||
|
Product valid = new Product("P-0001", "M6 Schraube", new BigDecimal("10.50"), MwstSatz.NORMAL, "Beschreibung", "Werkzeug");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> productService.saveProduct(valid));
|
||||||
|
assertTrue(productService.getProductById("P-0001").isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob Preise kleiner als 0.00 wie gefordert blockiert und mit einer Exception abgewiesen werden.
|
||||||
|
* Abdeckung von Anforderung: F-SH-PV-06 / AC-SH-PV-01 (Testfall: ProductServiceTest#testNegativePriceRejection)[cite: 142, 145, 159].
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNegativePriceRejection() {
|
||||||
|
// AC-SH-PV-01: Preis kleiner als 0.00 muss abgelehnt werden
|
||||||
|
Product invalidProduct = new Product("P-9999", "Testprodukt", new BigDecimal("-1.00"), MwstSatz.NORMAL, "Test", "Test");
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> {
|
||||||
|
productService.saveProduct(invalidProduct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Überprüft die Echtzeit-Filterung der Produktliste anhand einer Teilmenge des Namens,
|
||||||
|
* wobei Groß- und Kleinschreibung ignoriert werden muss.
|
||||||
|
* Abdeckung von Anforderung: F-SH-PV-05 / AC-SH-PV-03 (Testfall: ProductServiceTest#testSearchCaseInsensitive)[cite: 150, 153, 159].
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSearchCaseInsensitive() {
|
||||||
|
Product p1 = new Product("P-0001", "M6 Schraube", new BigDecimal("1.00"), MwstSatz.NORMAL, "", "");
|
||||||
|
Product p2 = new Product("P-0002", "M8 SCHRAUBE", new BigDecimal("2.00"), MwstSatz.NORMAL, "", "");
|
||||||
|
|
||||||
|
productService.saveProduct(p1);
|
||||||
|
productService.saveProduct(p2);
|
||||||
|
|
||||||
|
// Suche nach "schraube" muss im Ergebnis exakt beide Objekte liefern
|
||||||
|
List<Product> results = productService.searchProducts("schraube");
|
||||||
|
assertEquals(2, results.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package de.hsmannheim.faktura.produkt.service;
|
|
||||||
|
|
||||||
import de.hsmannheim.faktura.produkt.Produkt;
|
|
||||||
import de.hsmannheim.faktura.produkt.MwstSatz;
|
|
||||||
import de.hsmannheim.faktura.produkt.repository.ProduktRepository;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
public class ProduktServiceTest {
|
|
||||||
|
|
||||||
private ProduktService produktService;
|
|
||||||
private ProduktRepository produktRepository;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
public void setUp() {
|
|
||||||
produktRepository = new ProduktRepository();
|
|
||||||
produktService = new ProduktService(produktRepository);
|
|
||||||
|
|
||||||
// Cache leeren, damit alte JSON-Einträge die Tests nicht verfälschen
|
|
||||||
for (Produkt p : produktRepository.findAll()) {
|
|
||||||
produktRepository.deleteById(p.getProduktId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSaveValidProduct() {
|
|
||||||
Produkt valid = new Produkt("P-0001", "M6 Schraube", new BigDecimal("10.50"), MwstSatz.NORMAL, "Beschreibung", "Werkzeug");
|
|
||||||
assertDoesNotThrow(() -> produktService.saveProduct(valid));
|
|
||||||
assertTrue(produktService.getProductById("P-0001").isPresent());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testNegativePriceRejection() {
|
|
||||||
// AC-SH-PV-01: Preis kleiner als 0.00 muss abgelehnt werden
|
|
||||||
Produkt invalidProduct = new Produkt("P-9999", "Testprodukt", new BigDecimal("-1.00"), MwstSatz.NORMAL, "Test", "Test");
|
|
||||||
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> produktService.saveProduct(invalidProduct));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSearchCaseInsensitive() {
|
|
||||||
// AC-SH-PV-03: Suchtest für Groß-/Kleinschreibung
|
|
||||||
Produkt p1 = new Produkt("P-0001", "M6 Schraube", new BigDecimal("1.00"), MwstSatz.NORMAL, "", "");
|
|
||||||
Produkt p2 = new Produkt("P-0002", "M8 SCHRAUBE", new BigDecimal("2.00"), MwstSatz.NORMAL, "", "");
|
|
||||||
|
|
||||||
produktService.saveProduct(p1);
|
|
||||||
produktService.saveProduct(p2);
|
|
||||||
|
|
||||||
List<Produkt> results = produktService.searchProducts("schraube");
|
|
||||||
assertEquals(2, results.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue