From 87123df2e49d2a9059cb69c98f310cbfcd359d9e Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Wed, 18 Feb 2026 11:56:33 +0100 Subject: [PATCH] refactor: EntityDraft-Pattern auf Customer, Article und ProductCategory anwenden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomerDraft / CustomerUpdateDraft eingeführt - ArticleDraft / ArticleUpdateDraft eingeführt - ProductCategoryDraft / ProductCategoryUpdateDraft eingeführt - Customer.create() nimmt jetzt CustomerDraft, gibt Result zurück - Customer.update(CustomerUpdateDraft) ersetzt 4× updateXxx(VO) - Article.create() nimmt jetzt ArticleDraft statt VOs - Article.update(ArticleUpdateDraft) ersetzt rename() + changeCategory() - ProductCategory.create() nimmt jetzt ProductCategoryDraft, gibt Result zurück - ProductCategory.update(ProductCategoryUpdateDraft) ersetzt rename() + updateDescription() - Use Cases bauen Draft aus Command, kein VO-Wissen im Application Layer - CreateCustomerCommand / UpdateCustomerCommand: int → Integer für paymentDueDays - CLAUDE.md: EntityDraft-Pattern-Dokumentation ergänzt --- CLAUDE.md | 26 +++++ backend/docs/QUICK_START.md | 38 ++++++- .../application/masterdata/CreateArticle.java | 50 +++------ .../masterdata/CreateCustomer.java | 48 +++----- .../masterdata/CreateProductCategory.java | 24 ++-- .../masterdata/CreateSupplier.java | 52 ++++----- .../application/masterdata/UpdateArticle.java | 24 ++-- .../masterdata/UpdateCustomer.java | 48 +++----- .../masterdata/UpdateProductCategory.java | 24 ++-- .../masterdata/UpdateSupplier.java | 51 +++------ .../command/CreateCustomerCommand.java | 2 +- .../command/CreateSupplierCommand.java | 22 ++-- .../command/UpdateCustomerCommand.java | 2 +- .../command/UpdateSupplierCommand.java | 22 ++-- .../usermanagement/ChangePassword.java | 2 +- .../usermanagement/CreateUser.java | 2 +- .../effigenix/domain/masterdata/Article.java | 70 +++++++++--- .../domain/masterdata/ArticleDraft.java | 24 ++++ .../domain/masterdata/ArticleUpdateDraft.java | 14 +++ .../effigenix/domain/masterdata/Customer.java | 104 +++++++++++++----- .../domain/masterdata/CustomerDraft.java | 34 ++++++ .../masterdata/CustomerUpdateDraft.java | 32 ++++++ .../domain/masterdata/ProductCategory.java | 38 +++++-- .../masterdata/ProductCategoryDraft.java | 14 +++ .../ProductCategoryUpdateDraft.java | 14 +++ .../effigenix/domain/masterdata/Supplier.java | 103 ++++++++++++----- .../domain/masterdata/SupplierDraft.java | 32 ++++++ .../masterdata/SupplierUpdateDraft.java | 31 ++++++ .../audit/DatabaseAuditLogger.java | 5 - .../changelog/changes/004-seed-admin-user.sql | 2 +- 30 files changed, 625 insertions(+), 329 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/ArticleDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/ArticleUpdateDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/CustomerDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/CustomerUpdateDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryUpdateDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/SupplierDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/masterdata/SupplierUpdateDraft.java diff --git a/CLAUDE.md b/CLAUDE.md index db9477b..f292bfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,8 @@ Bounded Contexts: `usermanagement` (implementiert), `production`, `quality`, `in | Command | `{Verb}{Noun}Command` | `CreateUserCommand` | | Domain Entity | `{Noun}` | `User`, `Role` | | Value Object | `{Noun}` | `UserId`, `PasswordHash`, `RoleName` | +| Create-Draft | `{Noun}Draft` | `SupplierDraft` | +| Update-Draft | `{Noun}UpdateDraft` | `SupplierUpdateDraft` | | Domain Error | `{Noun}Error` (sealed interface) | `UserError.UsernameAlreadyExists` | | JPA Entity | `{Noun}Entity` | `UserEntity` | | Mapper | `{Noun}Mapper` | `UserMapper` (Domain↔JPA) | @@ -32,6 +34,30 @@ Bounded Contexts: `usermanagement` (implementiert), `production`, `quality`, `in | Web DTO | `{Verb}{Noun}Request` | `CreateUserRequest` | | Action Enum | `{Noun}Action implements Action` | `ProductionAction` | +## EntityDraft-Pattern + +Für Aggregate mit komplexer VO-Konstruktion (Address, ContactInfo, PaymentTerms u.ä.) gilt: +Der Application Layer baut **keine** VOs – er erzeugt einen **Draft-Record** mit rohen Strings +und übergibt ihn ans Aggregate. Das Aggregate orchestriert Validierung und VO-Konstruktion intern. + +```java +// Application Layer – nur Daten weitergeben, kein VO-Wissen +var draft = new SupplierDraft(cmd.name(), cmd.phone(), ...); +switch (Supplier.create(draft)) { ... } + +// Domain Layer – validiert intern, gibt Result zurück +public static Result create(SupplierDraft draft) { ... } +public Result update(SupplierUpdateDraft draft) { ... } +``` + +**Regeln:** +- Pflichtfelder: non-null im Draft-Record +- Optionale VOs (z.B. Address, PaymentTerms): `null`-Felder → VO wird nicht konstruiert +- Primitive `int` → `Integer` wenn das Feld optional/nullable sein muss +- Einzelne `updateXxx(VO)`-Methoden entfallen → ersetzt durch ein `update({Noun}UpdateDraft)` +- Uniqueness-Check bleibt im Application Layer (Repository-Concern), nach `Aggregate.create()` +- Invarianten-Kommentar im Aggregat aktuell halten + ## Error Handling Funktional via `Result` (`shared.common.Result`). Domain-Fehler sind sealed interfaces mit Records. Keine Exceptions im Domain/Application Layer. diff --git a/backend/docs/QUICK_START.md b/backend/docs/QUICK_START.md index 3ab671e..4d7a392 100644 --- a/backend/docs/QUICK_START.md +++ b/backend/docs/QUICK_START.md @@ -59,7 +59,7 @@ VALUES ( 'admin-001', 'admin', 'admin@effigenix.com', - '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYKKHFw3zqm', -- "admin123" + '$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi', -- "admin123" NULL, -- Kein Branch = globaler Zugriff 'ACTIVE', CURRENT_TIMESTAMP, @@ -127,7 +127,33 @@ curl -X POST http://localhost:8080/api/users \ }' ``` -## 6. Datenbank erkunden +## 6. BCrypt-Hash generieren + +Beim Anlegen von Seed-Daten (SQL-Migrations) werden BCrypt-Hashes benötigt. Da verschiedene Tools unterschiedliche Hashes erzeugen (gleiche Ausgabe, verschiedenes Salt – das ist gewollt), muss der Hash **mit Spring's BCryptPasswordEncoder** generiert werden, damit er zur App-Konfiguration passt. + +> **Wichtig:** Niemals einen Hash aus einem anderen Tool (htpasswd, online-Generatoren etc.) in Seed-Daten verwenden – nur Spring-generierte Hashes verifizieren korrekt. + +```bash +# Hash für ein Passwort generieren (Strength 12 wie in der App konfiguriert) +SPRING_CRYPTO=$(find ~/.m2/repository/org/springframework/security -name "spring-security-crypto-*.jar" | tail -1) +COMMONS=$(find ~/.m2/repository/commons-logging -name "*.jar" | tail -1) + +cat > /tmp/GenHash.java << 'EOF' +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +public class GenHash { + public static void main(String[] args) throws Exception { + String password = args.length > 0 ? args[0] : "admin123"; + BCryptPasswordEncoder enc = new BCryptPasswordEncoder(12); + System.out.println(enc.encode(password)); + } +} +EOF + +javac -cp "$SPRING_CRYPTO:$COMMONS" /tmp/GenHash.java -d /tmp +java -cp "/tmp:$SPRING_CRYPTO:$COMMONS" GenHash "meinPasswort" +``` + +## 7. Datenbank erkunden ```bash # PostgreSQL CLI @@ -141,7 +167,7 @@ SELECT * FROM role_permissions; -- Rollen-Permissions SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 10; -- Letzte 10 Audit Logs ``` -## 7. Development Workflow +## 8. Development Workflow ### Code-Änderungen testen @@ -166,7 +192,7 @@ mvn verify Bereits in `pom.xml` enthalten - Code-Änderungen werden automatisch neu geladen. -## 8. Typische Entwicklungs-Szenarien +## 9. Typische Entwicklungs-Szenarien ### Neuen User erstellen (via API) @@ -201,7 +227,7 @@ PUT /api/users/{userId}/password } ``` -## 9. Fehlersuche +## 10. Fehlersuche ### Port 8080 bereits belegt ```bash @@ -227,7 +253,7 @@ mvn flyway:info mvn flyway:repair ``` -## 10. Nächste Schritte +## 11. Nächste Schritte - 📖 Lies [USER_MANAGEMENT.md](./USER_MANAGEMENT.md) für Details - 🧪 Schreibe Integration Tests diff --git a/backend/src/main/java/de/effigenix/application/masterdata/CreateArticle.java b/backend/src/main/java/de/effigenix/application/masterdata/CreateArticle.java index 1c6a664..0db75e7 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/CreateArticle.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/CreateArticle.java @@ -2,13 +2,10 @@ package de.effigenix.application.masterdata; import de.effigenix.application.masterdata.command.CreateArticleCommand; import de.effigenix.domain.masterdata.*; -import de.effigenix.shared.common.Money; import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class CreateArticle { @@ -19,50 +16,31 @@ public class CreateArticle { } public Result execute(CreateArticleCommand cmd, ActorId performedBy) { - ArticleNumber articleNumber; - switch (ArticleNumber.of(cmd.articleNumber())) { - case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } - case Success(var val) -> articleNumber = val; + var draft = new ArticleDraft( + cmd.name(), cmd.articleNumber(), cmd.categoryId(), + cmd.unit(), cmd.priceModel(), cmd.price() + ); + + Article article; + switch (Article.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> article = val; } - switch (articleRepository.existsByArticleNumber(articleNumber)) { - case Failure(var err) -> + switch (articleRepository.existsByArticleNumber(article.articleNumber())) { + case Result.Failure(var err) -> { return Result.failure(new ArticleError.RepositoryFailure(err.message())); } - case Success(var exists) -> { + case Result.Success(var exists) -> { if (exists) { return Result.failure(new ArticleError.ArticleNumberAlreadyExists(cmd.articleNumber())); } } } - Money price; - switch (Money.tryEuro(cmd.price())) { - case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } - case Success(var val) -> price = val; - } - - SalesUnit salesUnit; - switch (SalesUnit.create(cmd.unit(), cmd.priceModel(), price)) { - case Failure(var err) -> { return Result.failure(err); } - case Success(var val) -> salesUnit = val; - } - - ArticleName name; - switch (ArticleName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } - case Success(var val) -> name = val; - } - - Article article; - switch (Article.create(name, articleNumber, ProductCategoryId.of(cmd.categoryId()), salesUnit)) { - case Failure(var err) -> { return Result.failure(err); } - case Success(var val) -> article = val; - } - switch (articleRepository.save(article)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new ArticleError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(article); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/CreateCustomer.java b/backend/src/main/java/de/effigenix/application/masterdata/CreateCustomer.java index 0252ce9..213a964 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/CreateCustomer.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/CreateCustomer.java @@ -2,12 +2,10 @@ package de.effigenix.application.masterdata; import de.effigenix.application.masterdata.command.CreateCustomerCommand; import de.effigenix.domain.masterdata.*; -import de.effigenix.shared.common.*; +import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class CreateCustomer { @@ -18,46 +16,32 @@ public class CreateCustomer { } public Result execute(CreateCustomerCommand cmd, ActorId performedBy) { - CustomerName name; - switch (CustomerName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> name = val; + var draft = new CustomerDraft( + cmd.name(), cmd.type(), cmd.phone(), cmd.email(), cmd.contactPerson(), + cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(), + cmd.paymentDueDays(), cmd.paymentDescription() + ); + + Customer customer; + switch (Customer.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> customer = val; } - switch (customerRepository.existsByName(name)) { - case Failure(var err) -> + switch (customerRepository.existsByName(customer.name())) { + case Result.Failure(var err) -> { return Result.failure(new CustomerError.RepositoryFailure(err.message())); } - case Success(var exists) -> { + case Result.Success(var exists) -> { if (exists) { return Result.failure(new CustomerError.CustomerNameAlreadyExists(cmd.name())); } } } - Address address; - switch (Address.create(cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> address = val; - } - - ContactInfo contactInfo; - switch (ContactInfo.create(cmd.phone(), cmd.email(), cmd.contactPerson())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> contactInfo = val; - } - - PaymentTerms paymentTerms; - switch (PaymentTerms.create(cmd.paymentDueDays(), cmd.paymentDescription())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> paymentTerms = val; - } - - var customer = Customer.create(name, cmd.type(), address, contactInfo, paymentTerms); - switch (customerRepository.save(customer)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new CustomerError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(customer); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/CreateProductCategory.java b/backend/src/main/java/de/effigenix/application/masterdata/CreateProductCategory.java index 4c17a60..87e8781 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/CreateProductCategory.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/CreateProductCategory.java @@ -6,8 +6,6 @@ import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class CreateProductCategory { @@ -18,28 +16,28 @@ public class CreateProductCategory { } public Result execute(CreateProductCategoryCommand cmd, ActorId performedBy) { - CategoryName name; - switch (CategoryName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new ProductCategoryError.ValidationFailure(msg)); } - case Success(var val) -> name = val; + var draft = new ProductCategoryDraft(cmd.name(), cmd.description()); + + ProductCategory category; + switch (ProductCategory.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> category = val; } - switch (categoryRepository.existsByName(name)) { - case Failure(var err) -> + switch (categoryRepository.existsByName(category.name())) { + case Result.Failure(var err) -> { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } - case Success(var exists) -> { + case Result.Success(var exists) -> { if (exists) { return Result.failure(new ProductCategoryError.CategoryNameAlreadyExists(cmd.name())); } } } - var category = ProductCategory.create(name, cmd.description()); - switch (categoryRepository.save(category)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(category); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/CreateSupplier.java b/backend/src/main/java/de/effigenix/application/masterdata/CreateSupplier.java index c0ee6de..f16ef0d 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/CreateSupplier.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/CreateSupplier.java @@ -2,12 +2,10 @@ package de.effigenix.application.masterdata; import de.effigenix.application.masterdata.command.CreateSupplierCommand; import de.effigenix.domain.masterdata.*; -import de.effigenix.shared.common.*; +import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class CreateSupplier { @@ -18,46 +16,36 @@ public class CreateSupplier { } public Result execute(CreateSupplierCommand cmd, ActorId performedBy) { - SupplierName name; - switch (SupplierName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> name = val; + // 1. Draft aus Command bauen (kein VO-Wissen im Use Case) + var draft = new SupplierDraft( + cmd.name(), cmd.phone(), cmd.email(), cmd.contactPerson(), + cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(), + cmd.paymentDueDays(), cmd.paymentDescription() + ); + + // 2. Aggregate erzeugen (validiert intern) + Supplier supplier; + switch (Supplier.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> supplier = val; } - switch (supplierRepository.existsByName(name)) { - case Failure(var err) -> + // 3. Uniqueness-Check (Application-Concern: braucht Repository) + switch (supplierRepository.existsByName(supplier.name())) { + case Result.Failure(var err) -> { return Result.failure(new SupplierError.RepositoryFailure(err.message())); } - case Success(var exists) -> { + case Result.Success(var exists) -> { if (exists) { return Result.failure(new SupplierError.SupplierNameAlreadyExists(cmd.name())); } } } - Address address; - switch (Address.create(cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> address = val; - } - - ContactInfo contactInfo; - switch (ContactInfo.create(cmd.phone(), cmd.email(), cmd.contactPerson())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> contactInfo = val; - } - - PaymentTerms paymentTerms; - switch (PaymentTerms.create(cmd.paymentDueDays(), cmd.paymentDescription())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> paymentTerms = val; - } - - var supplier = Supplier.create(name, address, contactInfo, paymentTerms); - + // 4. Speichern switch (supplierRepository.save(supplier)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new SupplierError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(supplier); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/UpdateArticle.java b/backend/src/main/java/de/effigenix/application/masterdata/UpdateArticle.java index 988a91d..2261bf5 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/UpdateArticle.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/UpdateArticle.java @@ -6,8 +6,6 @@ import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class UpdateArticle { @@ -22,9 +20,9 @@ public class UpdateArticle { Article article; switch (articleRepository.findById(articleId)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new ArticleError.RepositoryFailure(err.message())); } - case Success(var opt) -> { + case Result.Success(var opt) -> { if (opt.isEmpty()) { return Result.failure(new ArticleError.ArticleNotFound(articleId)); } @@ -32,22 +30,16 @@ public class UpdateArticle { } } - if (cmd.name() != null) { - ArticleName name; - switch (ArticleName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } - case Success(var val) -> name = val; - } - article.rename(name); - } - if (cmd.categoryId() != null) { - article.changeCategory(ProductCategoryId.of(cmd.categoryId())); + var draft = new ArticleUpdateDraft(cmd.name(), cmd.categoryId()); + switch (article.update(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } } switch (articleRepository.save(article)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new ArticleError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(article); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/UpdateCustomer.java b/backend/src/main/java/de/effigenix/application/masterdata/UpdateCustomer.java index a215020..4072352 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/UpdateCustomer.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/UpdateCustomer.java @@ -2,12 +2,10 @@ package de.effigenix.application.masterdata; import de.effigenix.application.masterdata.command.UpdateCustomerCommand; import de.effigenix.domain.masterdata.*; -import de.effigenix.shared.common.*; +import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class UpdateCustomer { @@ -22,9 +20,9 @@ public class UpdateCustomer { Customer customer; switch (customerRepository.findById(customerId)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new CustomerError.RepositoryFailure(err.message())); } - case Success(var opt) -> { + case Result.Success(var opt) -> { if (opt.isEmpty()) { return Result.failure(new CustomerError.CustomerNotFound(customerId)); } @@ -32,40 +30,20 @@ public class UpdateCustomer { } } - if (cmd.name() != null) { - CustomerName name; - switch (CustomerName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> name = val; - } - customer.updateName(name); + var draft = new CustomerUpdateDraft( + cmd.name(), cmd.phone(), cmd.email(), cmd.contactPerson(), + cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(), + cmd.paymentDueDays(), cmd.paymentDescription() + ); + switch (customer.update(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } } - Address address; - switch (Address.create(cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> address = val; - } - customer.updateBillingAddress(address); - - ContactInfo contactInfo; - switch (ContactInfo.create(cmd.phone(), cmd.email(), cmd.contactPerson())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> contactInfo = val; - } - customer.updateContactInfo(contactInfo); - - PaymentTerms paymentTerms; - switch (PaymentTerms.create(cmd.paymentDueDays(), cmd.paymentDescription())) { - case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } - case Success(var val) -> paymentTerms = val; - } - customer.updatePaymentTerms(paymentTerms); - switch (customerRepository.save(customer)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new CustomerError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(customer); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/UpdateProductCategory.java b/backend/src/main/java/de/effigenix/application/masterdata/UpdateProductCategory.java index 7248fec..81944a6 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/UpdateProductCategory.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/UpdateProductCategory.java @@ -6,8 +6,6 @@ import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class UpdateProductCategory { @@ -22,9 +20,9 @@ public class UpdateProductCategory { ProductCategory category; switch (categoryRepository.findById(categoryId)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } - case Success(var opt) -> { + case Result.Success(var opt) -> { if (opt.isEmpty()) { return Result.failure(new ProductCategoryError.CategoryNotFound(categoryId)); } @@ -32,22 +30,16 @@ public class UpdateProductCategory { } } - if (cmd.name() != null) { - CategoryName name; - switch (CategoryName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new ProductCategoryError.ValidationFailure(msg)); } - case Success(var val) -> name = val; - } - category.rename(name); - } - if (cmd.description() != null) { - category.updateDescription(cmd.description()); + var draft = new ProductCategoryUpdateDraft(cmd.name(), cmd.description()); + switch (category.update(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } } switch (categoryRepository.save(category)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(category); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/UpdateSupplier.java b/backend/src/main/java/de/effigenix/application/masterdata/UpdateSupplier.java index 892ba35..8df3c22 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/UpdateSupplier.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/UpdateSupplier.java @@ -2,12 +2,10 @@ package de.effigenix.application.masterdata; import de.effigenix.application.masterdata.command.UpdateSupplierCommand; import de.effigenix.domain.masterdata.*; -import de.effigenix.shared.common.*; +import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; import org.springframework.transaction.annotation.Transactional; -import static de.effigenix.shared.common.Result.*; - @Transactional public class UpdateSupplier { @@ -18,13 +16,14 @@ public class UpdateSupplier { } public Result execute(UpdateSupplierCommand cmd, ActorId performedBy) { + // 1. Laden var supplierId = SupplierId.of(cmd.supplierId()); Supplier supplier; switch (supplierRepository.findById(supplierId)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new SupplierError.RepositoryFailure(err.message())); } - case Success(var opt) -> { + case Result.Success(var opt) -> { if (opt.isEmpty()) { return Result.failure(new SupplierError.SupplierNotFound(supplierId)); } @@ -32,40 +31,22 @@ public class UpdateSupplier { } } - if (cmd.name() != null) { - SupplierName name; - switch (SupplierName.of(cmd.name())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> name = val; - } - supplier.updateName(name); + // 2. Draft bauen + Aggregate delegiert Validierung + var draft = new SupplierUpdateDraft( + cmd.name(), cmd.phone(), cmd.email(), cmd.contactPerson(), + cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(), + cmd.paymentDueDays(), cmd.paymentDescription() + ); + switch (supplier.update(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } } - Address address; - switch (Address.create(cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> address = val; - } - supplier.updateAddress(address); - - ContactInfo contactInfo; - switch (ContactInfo.create(cmd.phone(), cmd.email(), cmd.contactPerson())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> contactInfo = val; - } - supplier.updateContactInfo(contactInfo); - - PaymentTerms paymentTerms; - switch (PaymentTerms.create(cmd.paymentDueDays(), cmd.paymentDescription())) { - case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } - case Success(var val) -> paymentTerms = val; - } - supplier.updatePaymentTerms(paymentTerms); - + // 3. Speichern switch (supplierRepository.save(supplier)) { - case Failure(var err) -> + case Result.Failure(var err) -> { return Result.failure(new SupplierError.RepositoryFailure(err.message())); } - case Success(var ignored) -> { } + case Result.Success(var ignored) -> { } } return Result.success(supplier); diff --git a/backend/src/main/java/de/effigenix/application/masterdata/command/CreateCustomerCommand.java b/backend/src/main/java/de/effigenix/application/masterdata/command/CreateCustomerCommand.java index 7ab4d13..069a02a 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/command/CreateCustomerCommand.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/command/CreateCustomerCommand.java @@ -13,6 +13,6 @@ public record CreateCustomerCommand( String phone, String email, String contactPerson, - int paymentDueDays, + Integer paymentDueDays, String paymentDescription ) {} diff --git a/backend/src/main/java/de/effigenix/application/masterdata/command/CreateSupplierCommand.java b/backend/src/main/java/de/effigenix/application/masterdata/command/CreateSupplierCommand.java index 0c9e394..90f85a6 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/command/CreateSupplierCommand.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/command/CreateSupplierCommand.java @@ -1,15 +1,15 @@ package de.effigenix.application.masterdata.command; public record CreateSupplierCommand( - String name, - String street, - String houseNumber, - String postalCode, - String city, - String country, - String phone, - String email, - String contactPerson, - int paymentDueDays, - String paymentDescription + String name, // required + String phone, // required + String email, // optional + String contactPerson, // optional + String street, // optional + String houseNumber, // optional + String postalCode, // optional + String city, // optional + String country, // optional + Integer paymentDueDays, // optional + String paymentDescription // optional ) {} diff --git a/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateCustomerCommand.java b/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateCustomerCommand.java index 43746b5..0266855 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateCustomerCommand.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateCustomerCommand.java @@ -11,6 +11,6 @@ public record UpdateCustomerCommand( String phone, String email, String contactPerson, - int paymentDueDays, + Integer paymentDueDays, String paymentDescription ) {} diff --git a/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateSupplierCommand.java b/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateSupplierCommand.java index 409e1fa..5694d78 100644 --- a/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateSupplierCommand.java +++ b/backend/src/main/java/de/effigenix/application/masterdata/command/UpdateSupplierCommand.java @@ -2,15 +2,15 @@ package de.effigenix.application.masterdata.command; public record UpdateSupplierCommand( String supplierId, - String name, - String street, - String houseNumber, - String postalCode, - String city, - String country, - String phone, - String email, - String contactPerson, - int paymentDueDays, - String paymentDescription + String name, // null = nicht ändern + String phone, // null = nicht ändern + String email, // null = nicht ändern + String contactPerson, // null = nicht ändern + String street, // null = Address nicht ändern + String houseNumber, // null = Address nicht ändern + String postalCode, // null = Address nicht ändern + String city, // null = Address nicht ändern + String country, // null = Address nicht ändern + Integer paymentDueDays, // null = PaymentTerms nicht ändern + String paymentDescription // null = PaymentTerms nicht ändern ) {} diff --git a/backend/src/main/java/de/effigenix/application/usermanagement/ChangePassword.java b/backend/src/main/java/de/effigenix/application/usermanagement/ChangePassword.java index 5ffa6d7..b3291d6 100644 --- a/backend/src/main/java/de/effigenix/application/usermanagement/ChangePassword.java +++ b/backend/src/main/java/de/effigenix/application/usermanagement/ChangePassword.java @@ -56,7 +56,7 @@ public class ChangePassword { // 3. Validate new password if (!passwordHasher.isValidPassword(cmd.newPassword())) { - return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters")); + return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters and contain uppercase, lowercase, digit, and special character")); } // 4. Hash new password diff --git a/backend/src/main/java/de/effigenix/application/usermanagement/CreateUser.java b/backend/src/main/java/de/effigenix/application/usermanagement/CreateUser.java index 570b0fa..6320f49 100644 --- a/backend/src/main/java/de/effigenix/application/usermanagement/CreateUser.java +++ b/backend/src/main/java/de/effigenix/application/usermanagement/CreateUser.java @@ -44,7 +44,7 @@ public class CreateUser { public Result execute(CreateUserCommand cmd, ActorId performedBy) { // 1. Validate password if (!passwordHasher.isValidPassword(cmd.password())) { - return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters")); + return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters and contain uppercase, lowercase, digit, and special character")); } // 2. Check username uniqueness diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/Article.java b/backend/src/main/java/de/effigenix/domain/masterdata/Article.java index 4b8d056..6c04199 100644 --- a/backend/src/main/java/de/effigenix/domain/masterdata/Article.java +++ b/backend/src/main/java/de/effigenix/domain/masterdata/Article.java @@ -3,6 +3,8 @@ package de.effigenix.domain.masterdata; import de.effigenix.shared.common.Money; import de.effigenix.shared.common.Result; +import static de.effigenix.shared.common.Result.*; + import java.time.LocalDateTime; import java.util.*; @@ -49,22 +51,50 @@ public class Article { this.updatedAt = updatedAt; } - public static Result create( - ArticleName name, - ArticleNumber articleNumber, - ProductCategoryId categoryId, - SalesUnit initialSalesUnit - ) { - if (initialSalesUnit == null) { - return Result.failure(new ArticleError.MinimumSalesUnitRequired()); + /** + * Factory: Erzeugt einen neuen Article aus rohen Eingaben. + * Orchestriert Validierung aller VOs intern. + * Alle Felder sind Pflicht. + */ + public static Result create(ArticleDraft draft) { + // 1. ArticleName validieren + ArticleName name; + switch (ArticleName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } + case Success(var val) -> name = val; } + + // 2. ArticleNumber validieren + ArticleNumber articleNumber; + switch (ArticleNumber.of(draft.articleNumber())) { + case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } + case Success(var val) -> articleNumber = val; + } + + // 3. ProductCategoryId – reines UUID-Wrapping + var categoryId = ProductCategoryId.of(draft.categoryId()); + + // 4. Money validieren + Money price; + switch (Money.tryEuro(draft.price())) { + case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } + case Success(var val) -> price = val; + } + + // 5. SalesUnit erzeugen (initiale Pflicht-Einheit) + SalesUnit salesUnit; + switch (SalesUnit.create(draft.unit(), draft.priceModel(), price)) { + case Failure(var err) -> { return Result.failure(err); } + case Success(var val) -> salesUnit = val; + } + var now = LocalDateTime.now(); return Result.success(new Article( ArticleId.generate(), name, articleNumber, categoryId, - List.of(initialSalesUnit), + List.of(salesUnit), ArticleStatus.ACTIVE, Set.of(), now, @@ -124,14 +154,22 @@ public class Article { // ==================== Article Properties ==================== - public void rename(ArticleName newName) { - this.name = newName; - touch(); - } - - public void changeCategory(ProductCategoryId newCategoryId) { - this.categoryId = newCategoryId; + /** + * Wendet partielle Updates an. Das Aggregate validiert intern. + * null-Felder im Draft werden ignoriert. + */ + public Result update(ArticleUpdateDraft draft) { + if (draft.name() != null) { + switch (ArticleName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } + case Success(var val) -> this.name = val; + } + } + if (draft.categoryId() != null) { + this.categoryId = ProductCategoryId.of(draft.categoryId()); + } touch(); + return Result.success(null); } public void activate() { diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/ArticleDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/ArticleDraft.java new file mode 100644 index 0000000..09151ac --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/ArticleDraft.java @@ -0,0 +1,24 @@ +package de.effigenix.domain.masterdata; + +import java.math.BigDecimal; + +/** + * Rohe Eingabe zum Erzeugen eines Article. + * Wird vom Application Layer aus dem Command gebaut und an Article.create() übergeben. + * Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion. + * + * @param name Pflicht + * @param articleNumber Pflicht + * @param categoryId Pflicht (UUID-String) + * @param unit Pflicht (Enum) + * @param priceModel Pflicht (Enum) + * @param price Pflicht (Article muss mind. eine SalesUnit haben) + */ +public record ArticleDraft( + String name, + String articleNumber, + String categoryId, + Unit unit, + PriceModel priceModel, + BigDecimal price +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/ArticleUpdateDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/ArticleUpdateDraft.java new file mode 100644 index 0000000..dbaf1b5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/ArticleUpdateDraft.java @@ -0,0 +1,14 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe für partielle Article-Updates. + * null-Felder bedeuten "nicht ändern". + * Wird vom Application Layer aus dem Command gebaut und an Article.update() übergeben. + * + * @param name null = nicht ändern + * @param categoryId null = nicht ändern + */ +public record ArticleUpdateDraft( + String name, + String categoryId +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/Customer.java b/backend/src/main/java/de/effigenix/domain/masterdata/Customer.java index 5752da6..b930761 100644 --- a/backend/src/main/java/de/effigenix/domain/masterdata/Customer.java +++ b/backend/src/main/java/de/effigenix/domain/masterdata/Customer.java @@ -6,6 +6,8 @@ import de.effigenix.shared.common.Money; import de.effigenix.shared.common.PaymentTerms; import de.effigenix.shared.common.Result; +import static de.effigenix.shared.common.Result.*; + import java.time.LocalDateTime; import java.util.*; @@ -13,9 +15,11 @@ import java.util.*; * Aggregate Root for Customer. * * Invariants: - * 1. Name, CustomerType, billingAddress non-null - * 2. FrameContract only for B2B customers - * 3. No duplicate ContractLineItems per ArticleId (enforced by FrameContract) + * 1. Name, CustomerType und billingAddress sind Pflicht + * 2. ContactInfo (phone) ist Pflicht + * 3. PaymentTerms ist optional + * 4. FrameContract nur für B2B-Kunden + * 5. No duplicate ContractLineItems per ArticleId (enforced by FrameContract) */ public class Customer { @@ -60,18 +64,47 @@ public class Customer { this.updatedAt = updatedAt; } - public static Customer create( - CustomerName name, - CustomerType type, - Address billingAddress, - ContactInfo contactInfo, - PaymentTerms paymentTerms - ) { + /** + * Factory: Erzeugt einen neuen Customer aus rohen Eingaben. + * Orchestriert Validierung aller VOs intern. + * Pflicht: name, type, phone, street, city, country. Optional: alles andere. + */ + public static Result create(CustomerDraft draft) { + // 1. Name validieren (Pflicht) + CustomerName name; + switch (CustomerName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> name = val; + } + + // 2. ContactInfo validieren (Pflicht: phone; optional: email, contactPerson) + ContactInfo contactInfo; + switch (ContactInfo.create(draft.phone(), draft.email(), draft.contactPerson())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> contactInfo = val; + } + + // 3. billingAddress (Pflicht-Invariante) + Address billingAddress; + switch (Address.create(draft.street(), draft.houseNumber(), draft.postalCode(), draft.city(), draft.country())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> billingAddress = val; + } + + // 4. PaymentTerms optional + PaymentTerms paymentTerms = null; + if (draft.paymentDueDays() != null) { + switch (PaymentTerms.create(draft.paymentDueDays(), draft.paymentDescription())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> paymentTerms = val; + } + } + var now = LocalDateTime.now(); - return new Customer( - CustomerId.generate(), name, type, billingAddress, contactInfo, paymentTerms, + return Result.success(new Customer( + CustomerId.generate(), name, draft.type(), billingAddress, contactInfo, paymentTerms, List.of(), null, Set.of(), CustomerStatus.ACTIVE, now, now - ); + )); } public static Customer reconstitute( @@ -94,24 +127,41 @@ public class Customer { // ==================== Business Methods ==================== - public void updateName(CustomerName newName) { - this.name = newName; - touch(); - } + /** + * Wendet partielle Updates an. Das Aggregate validiert intern. + * null-Felder im Draft werden ignoriert. + */ + public Result update(CustomerUpdateDraft draft) { + if (draft.name() != null) { + switch (CustomerName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> this.name = val; + } + } - public void updateBillingAddress(Address newAddress) { - this.billingAddress = newAddress; - touch(); - } + if (draft.phone() != null) { + switch (ContactInfo.create(draft.phone(), draft.email(), draft.contactPerson())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> this.contactInfo = val; + } + } - public void updateContactInfo(ContactInfo newContactInfo) { - this.contactInfo = newContactInfo; - touch(); - } + if (draft.street() != null || draft.city() != null) { + switch (Address.create(draft.street(), draft.houseNumber(), draft.postalCode(), draft.city(), draft.country())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> this.billingAddress = val; + } + } + + if (draft.paymentDueDays() != null) { + switch (PaymentTerms.create(draft.paymentDueDays(), draft.paymentDescription())) { + case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } + case Success(var val) -> this.paymentTerms = val; + } + } - public void updatePaymentTerms(PaymentTerms newPaymentTerms) { - this.paymentTerms = newPaymentTerms; touch(); + return Result.success(null); } // ==================== Delivery Addresses ==================== diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/CustomerDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/CustomerDraft.java new file mode 100644 index 0000000..192c8b7 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/CustomerDraft.java @@ -0,0 +1,34 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe zum Erzeugen eines Customer. + * Wird vom Application Layer aus dem Command gebaut und an Customer.create() übergeben. + * Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion. + * + * @param name Pflicht + * @param type Pflicht (Enum) + * @param phone Pflicht (Teil von ContactInfo) + * @param email Optional + * @param contactPerson Optional + * @param street Pflicht (billingAddress ist Pflicht-Invariante) + * @param houseNumber Optional + * @param postalCode Optional + * @param city Pflicht + * @param country Pflicht (ISO 3166-1 alpha-2) + * @param paymentDueDays Optional + * @param paymentDescription Optional + */ +public record CustomerDraft( + String name, + CustomerType type, + String phone, + String email, + String contactPerson, + String street, + String houseNumber, + String postalCode, + String city, + String country, + Integer paymentDueDays, + String paymentDescription +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/CustomerUpdateDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/CustomerUpdateDraft.java new file mode 100644 index 0000000..1e9a638 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/CustomerUpdateDraft.java @@ -0,0 +1,32 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe für partielle Customer-Updates. + * null-Felder bedeuten "nicht ändern". + * Wird vom Application Layer aus dem Command gebaut und an Customer.update() übergeben. + * + * @param name null = nicht ändern + * @param phone null = ContactInfo nicht ändern + * @param email Teil von ContactInfo (nur relevant wenn phone != null) + * @param contactPerson Teil von ContactInfo (nur relevant wenn phone != null) + * @param street null = billingAddress nicht ändern + * @param houseNumber Teil von billingAddress + * @param postalCode Teil von billingAddress + * @param city Teil von billingAddress + * @param country Teil von billingAddress + * @param paymentDueDays null = PaymentTerms nicht ändern + * @param paymentDescription Teil von PaymentTerms + */ +public record CustomerUpdateDraft( + String name, + String phone, + String email, + String contactPerson, + String street, + String houseNumber, + String postalCode, + String city, + String country, + Integer paymentDueDays, + String paymentDescription +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategory.java b/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategory.java index 7bd5642..b8ebba5 100644 --- a/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategory.java +++ b/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategory.java @@ -1,5 +1,9 @@ package de.effigenix.domain.masterdata; +import de.effigenix.shared.common.Result; + +import static de.effigenix.shared.common.Result.*; + /** * Mini-Aggregate for product categories. * DB-based entity, dynamically extendable by users. @@ -16,20 +20,38 @@ public class ProductCategory { this.description = description; } - public static ProductCategory create(CategoryName name, String description) { - return new ProductCategory(ProductCategoryId.generate(), name, description); + /** + * Factory: Erzeugt eine neue ProductCategory aus rohen Eingaben. + * Orchestriert Validierung des CategoryName-VO intern. + */ + public static Result create(ProductCategoryDraft draft) { + CategoryName name; + switch (CategoryName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new ProductCategoryError.ValidationFailure(msg)); } + case Success(var val) -> name = val; + } + return Result.success(new ProductCategory(ProductCategoryId.generate(), name, draft.description())); } public static ProductCategory reconstitute(ProductCategoryId id, CategoryName name, String description) { return new ProductCategory(id, name, description); } - public void rename(CategoryName newName) { - this.name = newName; - } - - public void updateDescription(String newDescription) { - this.description = newDescription; + /** + * Wendet partielle Updates an. Das Aggregate validiert intern. + * null-Felder im Draft werden ignoriert. + */ + public Result update(ProductCategoryUpdateDraft draft) { + if (draft.name() != null) { + switch (CategoryName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new ProductCategoryError.ValidationFailure(msg)); } + case Success(var val) -> this.name = val; + } + } + if (draft.description() != null) { + this.description = draft.description(); + } + return Result.success(null); } public ProductCategoryId id() { return id; } diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryDraft.java new file mode 100644 index 0000000..3ecd401 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryDraft.java @@ -0,0 +1,14 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe zum Erzeugen einer ProductCategory. + * Wird vom Application Layer aus dem Command gebaut und an ProductCategory.create() übergeben. + * Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion. + * + * @param name Pflicht + * @param description Optional + */ +public record ProductCategoryDraft( + String name, + String description +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryUpdateDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryUpdateDraft.java new file mode 100644 index 0000000..1b3e506 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/ProductCategoryUpdateDraft.java @@ -0,0 +1,14 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe für partielle ProductCategory-Updates. + * null-Felder bedeuten "nicht ändern". + * Wird vom Application Layer aus dem Command gebaut und an ProductCategory.update() übergeben. + * + * @param name null = nicht ändern + * @param description null = nicht ändern + */ +public record ProductCategoryUpdateDraft( + String name, + String description +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/Supplier.java b/backend/src/main/java/de/effigenix/domain/masterdata/Supplier.java index 5a6c869..75946cb 100644 --- a/backend/src/main/java/de/effigenix/domain/masterdata/Supplier.java +++ b/backend/src/main/java/de/effigenix/domain/masterdata/Supplier.java @@ -1,5 +1,6 @@ package de.effigenix.domain.masterdata; +import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Address; import de.effigenix.shared.common.ContactInfo; import de.effigenix.shared.common.PaymentTerms; @@ -9,13 +10,16 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import static de.effigenix.shared.common.Result.*; + /** * Aggregate Root for Supplier. * * Invariants: - * 1. Name, Address, PaymentTerms non-null - * 2. No structurally identical certificates (record equality) - * 3. Rating scores 1-5 (enforced by SupplierRating VO) + * 1. Name und ContactInfo (phone) sind Pflicht + * 2. Address und PaymentTerms sind optional + * 3. No structurally identical certificates (record equality) + * 4. Rating scores 1-5 (enforced by SupplierRating VO) */ public class Supplier { @@ -54,17 +58,49 @@ public class Supplier { this.updatedAt = updatedAt; } - public static Supplier create( - SupplierName name, - Address address, - ContactInfo contactInfo, - PaymentTerms paymentTerms - ) { + /** + * Factory: Erzeugt einen neuen Supplier aus rohen Eingaben. + * Orchestriert Validierung aller VOs intern. + * Pflicht: name, phone. Optional: alles andere. + */ + public static Result create(SupplierDraft draft) { + // 1. Name validieren (Pflicht) + SupplierName name; + switch (SupplierName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> name = val; + } + + // 2. ContactInfo validieren (Pflicht: phone; optional: email, contactPerson) + ContactInfo contactInfo; + switch (ContactInfo.create(draft.phone(), draft.email(), draft.contactPerson())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> contactInfo = val; + } + + // 3. Address optional – nur wenn mindestens street oder city gesetzt + Address address = null; + if (draft.street() != null || draft.city() != null) { + switch (Address.create(draft.street(), draft.houseNumber(), draft.postalCode(), draft.city(), draft.country())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> address = val; + } + } + + // 4. PaymentTerms optional + PaymentTerms paymentTerms = null; + if (draft.paymentDueDays() != null) { + switch (PaymentTerms.create(draft.paymentDueDays(), draft.paymentDescription())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> paymentTerms = val; + } + } + var now = LocalDateTime.now(); - return new Supplier( + return Result.success(new Supplier( SupplierId.generate(), name, address, contactInfo, paymentTerms, List.of(), null, SupplierStatus.ACTIVE, now, now - ); + )); } public static Supplier reconstitute( @@ -85,24 +121,41 @@ public class Supplier { // ==================== Business Methods ==================== - public void updateName(SupplierName newName) { - this.name = newName; - touch(); - } + /** + * Wendet partielle Updates an. Das Aggregate validiert intern. + * null-Felder im Draft werden ignoriert. + */ + public Result update(SupplierUpdateDraft draft) { + if (draft.name() != null) { + switch (SupplierName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> this.name = val; + } + } - public void updateAddress(Address newAddress) { - this.address = newAddress; - touch(); - } + if (draft.phone() != null) { + switch (ContactInfo.create(draft.phone(), draft.email(), draft.contactPerson())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> this.contactInfo = val; + } + } - public void updateContactInfo(ContactInfo newContactInfo) { - this.contactInfo = newContactInfo; - touch(); - } + if (draft.street() != null || draft.city() != null) { + switch (Address.create(draft.street(), draft.houseNumber(), draft.postalCode(), draft.city(), draft.country())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> this.address = val; + } + } + + if (draft.paymentDueDays() != null) { + switch (PaymentTerms.create(draft.paymentDueDays(), draft.paymentDescription())) { + case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } + case Success(var val) -> this.paymentTerms = val; + } + } - public void updatePaymentTerms(PaymentTerms newPaymentTerms) { - this.paymentTerms = newPaymentTerms; touch(); + return Result.success(null); } public void addCertificate(QualityCertificate certificate) { diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/SupplierDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/SupplierDraft.java new file mode 100644 index 0000000..73dc854 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/SupplierDraft.java @@ -0,0 +1,32 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe zum Erzeugen eines Supplier. + * Wird vom Application Layer aus dem Command gebaut und an Supplier.create() übergeben. + * Das Aggregate ist verantwortlich für Validierung und VO-Konstruktion. + * + * @param name Pflicht + * @param phone Pflicht (Teil von ContactInfo) + * @param email Optional + * @param contactPerson Optional + * @param street Optional – nur wenn Address insgesamt vollständig + * @param houseNumber Optional + * @param postalCode Optional + * @param city Optional + * @param country Optional + * @param paymentDueDays Optional + * @param paymentDescription Optional + */ +public record SupplierDraft( + String name, + String phone, + String email, + String contactPerson, + String street, + String houseNumber, + String postalCode, + String city, + String country, + Integer paymentDueDays, + String paymentDescription +) {} diff --git a/backend/src/main/java/de/effigenix/domain/masterdata/SupplierUpdateDraft.java b/backend/src/main/java/de/effigenix/domain/masterdata/SupplierUpdateDraft.java new file mode 100644 index 0000000..9f775e2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/masterdata/SupplierUpdateDraft.java @@ -0,0 +1,31 @@ +package de.effigenix.domain.masterdata; + +/** + * Rohe Eingabe für partielle Supplier-Updates. + * null-Felder bedeuten "nicht ändern". + * + * @param name null = nicht ändern + * @param phone null = nicht ändern + * @param email null = nicht ändern + * @param contactPerson null = nicht ändern + * @param street null = Address nicht ändern + * @param houseNumber null = Address nicht ändern + * @param postalCode null = Address nicht ändern + * @param city null = Address nicht ändern + * @param country null = Address nicht ändern + * @param paymentDueDays null = PaymentTerms nicht ändern + * @param paymentDescription null = PaymentTerms nicht ändern + */ +public record SupplierUpdateDraft( + String name, + String phone, + String email, + String contactPerson, + String street, + String houseNumber, + String postalCode, + String city, + String country, + Integer paymentDueDays, + String paymentDescription +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/audit/DatabaseAuditLogger.java b/backend/src/main/java/de/effigenix/infrastructure/audit/DatabaseAuditLogger.java index 0f5a9b7..4556efb 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/audit/DatabaseAuditLogger.java +++ b/backend/src/main/java/de/effigenix/infrastructure/audit/DatabaseAuditLogger.java @@ -6,7 +6,6 @@ import de.effigenix.shared.security.ActorId; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -20,7 +19,6 @@ import java.util.UUID; * Database-backed implementation of AuditLogger. * * HACCP/GoBD Compliance: - * - All operations are async (@Async) for performance (don't block business logic) * - Logs are written to database for durability * - Logs are immutable after creation * - Includes IP address and user agent for forensics @@ -43,7 +41,6 @@ public class DatabaseAuditLogger implements AuditLogger { } @Override - @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(AuditEvent event, String entityId, ActorId performedBy) { try { @@ -67,7 +64,6 @@ public class DatabaseAuditLogger implements AuditLogger { } @Override - @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(AuditEvent event, String details) { try { @@ -90,7 +86,6 @@ public class DatabaseAuditLogger implements AuditLogger { } @Override - @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(AuditEvent event, ActorId performedBy) { try { diff --git a/backend/src/main/resources/db/changelog/changes/004-seed-admin-user.sql b/backend/src/main/resources/db/changelog/changes/004-seed-admin-user.sql index 4c15ccb..623a023 100644 --- a/backend/src/main/resources/db/changelog/changes/004-seed-admin-user.sql +++ b/backend/src/main/resources/db/changelog/changes/004-seed-admin-user.sql @@ -9,7 +9,7 @@ VALUES ( '00000000-0000-0000-0000-000000000001', -- Fixed UUID for admin 'admin', 'admin@effigenix.com', - '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYKKHFw3zqm', -- BCrypt hash for "admin123" + '$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi', -- BCrypt hash for "admin123" NULL, -- No branch = global access 'ACTIVE', CURRENT_TIMESTAMP,