1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:19:35 +01:00

refactor: EntityDraft-Pattern auf Customer, Article und ProductCategory anwenden

- 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
This commit is contained in:
Sebastian Frick 2026-02-18 11:56:33 +01:00
parent 6b341f217b
commit 87123df2e4
30 changed files with 625 additions and 329 deletions

View file

@ -23,6 +23,8 @@ Bounded Contexts: `usermanagement` (implementiert), `production`, `quality`, `in
| Command | `{Verb}{Noun}Command` | `CreateUserCommand` | | Command | `{Verb}{Noun}Command` | `CreateUserCommand` |
| Domain Entity | `{Noun}` | `User`, `Role` | | Domain Entity | `{Noun}` | `User`, `Role` |
| Value Object | `{Noun}` | `UserId`, `PasswordHash`, `RoleName` | | Value Object | `{Noun}` | `UserId`, `PasswordHash`, `RoleName` |
| Create-Draft | `{Noun}Draft` | `SupplierDraft` |
| Update-Draft | `{Noun}UpdateDraft` | `SupplierUpdateDraft` |
| Domain Error | `{Noun}Error` (sealed interface) | `UserError.UsernameAlreadyExists` | | Domain Error | `{Noun}Error` (sealed interface) | `UserError.UsernameAlreadyExists` |
| JPA Entity | `{Noun}Entity` | `UserEntity` | | JPA Entity | `{Noun}Entity` | `UserEntity` |
| Mapper | `{Noun}Mapper` | `UserMapper` (Domain↔JPA) | | Mapper | `{Noun}Mapper` | `UserMapper` (Domain↔JPA) |
@ -32,6 +34,30 @@ Bounded Contexts: `usermanagement` (implementiert), `production`, `quality`, `in
| Web DTO | `{Verb}{Noun}Request` | `CreateUserRequest` | | Web DTO | `{Verb}{Noun}Request` | `CreateUserRequest` |
| Action Enum | `{Noun}Action implements Action` | `ProductionAction` | | 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<SupplierError, Supplier> create(SupplierDraft draft) { ... }
public Result<SupplierError, Void> 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 ## Error Handling
Funktional via `Result<E, T>` (`shared.common.Result`). Domain-Fehler sind sealed interfaces mit Records. Keine Exceptions im Domain/Application Layer. Funktional via `Result<E, T>` (`shared.common.Result`). Domain-Fehler sind sealed interfaces mit Records. Keine Exceptions im Domain/Application Layer.

View file

@ -59,7 +59,7 @@ VALUES (
'admin-001', 'admin-001',
'admin', 'admin',
'admin@effigenix.com', 'admin@effigenix.com',
'$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYKKHFw3zqm', -- "admin123" '$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi', -- "admin123"
NULL, -- Kein Branch = globaler Zugriff NULL, -- Kein Branch = globaler Zugriff
'ACTIVE', 'ACTIVE',
CURRENT_TIMESTAMP, 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 ```bash
# PostgreSQL CLI # 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 SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 10; -- Letzte 10 Audit Logs
``` ```
## 7. Development Workflow ## 8. Development Workflow
### Code-Änderungen testen ### Code-Änderungen testen
@ -166,7 +192,7 @@ mvn verify
Bereits in `pom.xml` enthalten - Code-Änderungen werden automatisch neu geladen. Bereits in `pom.xml` enthalten - Code-Änderungen werden automatisch neu geladen.
## 8. Typische Entwicklungs-Szenarien ## 9. Typische Entwicklungs-Szenarien
### Neuen User erstellen (via API) ### Neuen User erstellen (via API)
@ -201,7 +227,7 @@ PUT /api/users/{userId}/password
} }
``` ```
## 9. Fehlersuche ## 10. Fehlersuche
### Port 8080 bereits belegt ### Port 8080 bereits belegt
```bash ```bash
@ -227,7 +253,7 @@ mvn flyway:info
mvn flyway:repair mvn flyway:repair
``` ```
## 10. Nächste Schritte ## 11. Nächste Schritte
- 📖 Lies [USER_MANAGEMENT.md](./USER_MANAGEMENT.md) für Details - 📖 Lies [USER_MANAGEMENT.md](./USER_MANAGEMENT.md) für Details
- 🧪 Schreibe Integration Tests - 🧪 Schreibe Integration Tests

View file

@ -2,13 +2,10 @@ package de.effigenix.application.masterdata;
import de.effigenix.application.masterdata.command.CreateArticleCommand; import de.effigenix.application.masterdata.command.CreateArticleCommand;
import de.effigenix.domain.masterdata.*; import de.effigenix.domain.masterdata.*;
import de.effigenix.shared.common.Money;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class CreateArticle { public class CreateArticle {
@ -19,50 +16,31 @@ public class CreateArticle {
} }
public Result<ArticleError, Article> execute(CreateArticleCommand cmd, ActorId performedBy) { public Result<ArticleError, Article> execute(CreateArticleCommand cmd, ActorId performedBy) {
ArticleNumber articleNumber; var draft = new ArticleDraft(
switch (ArticleNumber.of(cmd.articleNumber())) { cmd.name(), cmd.articleNumber(), cmd.categoryId(),
case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } cmd.unit(), cmd.priceModel(), cmd.price()
case Success(var val) -> articleNumber = val; );
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)) { switch (articleRepository.existsByArticleNumber(article.articleNumber())) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ArticleError.RepositoryFailure(err.message())); } { return Result.failure(new ArticleError.RepositoryFailure(err.message())); }
case Success(var exists) -> { case Result.Success(var exists) -> {
if (exists) { if (exists) {
return Result.failure(new ArticleError.ArticleNumberAlreadyExists(cmd.articleNumber())); 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)) { switch (articleRepository.save(article)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ArticleError.RepositoryFailure(err.message())); } { return Result.failure(new ArticleError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(article); return Result.success(article);

View file

@ -2,12 +2,10 @@ package de.effigenix.application.masterdata;
import de.effigenix.application.masterdata.command.CreateCustomerCommand; import de.effigenix.application.masterdata.command.CreateCustomerCommand;
import de.effigenix.domain.masterdata.*; import de.effigenix.domain.masterdata.*;
import de.effigenix.shared.common.*; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class CreateCustomer { public class CreateCustomer {
@ -18,46 +16,32 @@ public class CreateCustomer {
} }
public Result<CustomerError, Customer> execute(CreateCustomerCommand cmd, ActorId performedBy) { public Result<CustomerError, Customer> execute(CreateCustomerCommand cmd, ActorId performedBy) {
CustomerName name; var draft = new CustomerDraft(
switch (CustomerName.of(cmd.name())) { cmd.name(), cmd.type(), cmd.phone(), cmd.email(), cmd.contactPerson(),
case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(),
case Success(var val) -> name = val; 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)) { switch (customerRepository.existsByName(customer.name())) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new CustomerError.RepositoryFailure(err.message())); } { return Result.failure(new CustomerError.RepositoryFailure(err.message())); }
case Success(var exists) -> { case Result.Success(var exists) -> {
if (exists) { if (exists) {
return Result.failure(new CustomerError.CustomerNameAlreadyExists(cmd.name())); 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)) { switch (customerRepository.save(customer)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new CustomerError.RepositoryFailure(err.message())); } { return Result.failure(new CustomerError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(customer); return Result.success(customer);

View file

@ -6,8 +6,6 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class CreateProductCategory { public class CreateProductCategory {
@ -18,28 +16,28 @@ public class CreateProductCategory {
} }
public Result<ProductCategoryError, ProductCategory> execute(CreateProductCategoryCommand cmd, ActorId performedBy) { public Result<ProductCategoryError, ProductCategory> execute(CreateProductCategoryCommand cmd, ActorId performedBy) {
CategoryName name; var draft = new ProductCategoryDraft(cmd.name(), cmd.description());
switch (CategoryName.of(cmd.name())) {
case Failure(var msg) -> { return Result.failure(new ProductCategoryError.ValidationFailure(msg)); } ProductCategory category;
case Success(var val) -> name = val; switch (ProductCategory.create(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> category = val;
} }
switch (categoryRepository.existsByName(name)) { switch (categoryRepository.existsByName(category.name())) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); }
case Success(var exists) -> { case Result.Success(var exists) -> {
if (exists) { if (exists) {
return Result.failure(new ProductCategoryError.CategoryNameAlreadyExists(cmd.name())); return Result.failure(new ProductCategoryError.CategoryNameAlreadyExists(cmd.name()));
} }
} }
} }
var category = ProductCategory.create(name, cmd.description());
switch (categoryRepository.save(category)) { switch (categoryRepository.save(category)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(category); return Result.success(category);

View file

@ -2,12 +2,10 @@ package de.effigenix.application.masterdata;
import de.effigenix.application.masterdata.command.CreateSupplierCommand; import de.effigenix.application.masterdata.command.CreateSupplierCommand;
import de.effigenix.domain.masterdata.*; import de.effigenix.domain.masterdata.*;
import de.effigenix.shared.common.*; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class CreateSupplier { public class CreateSupplier {
@ -18,46 +16,36 @@ public class CreateSupplier {
} }
public Result<SupplierError, Supplier> execute(CreateSupplierCommand cmd, ActorId performedBy) { public Result<SupplierError, Supplier> execute(CreateSupplierCommand cmd, ActorId performedBy) {
SupplierName name; // 1. Draft aus Command bauen (kein VO-Wissen im Use Case)
switch (SupplierName.of(cmd.name())) { var draft = new SupplierDraft(
case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } cmd.name(), cmd.phone(), cmd.email(), cmd.contactPerson(),
case Success(var val) -> name = val; 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)) { // 3. Uniqueness-Check (Application-Concern: braucht Repository)
case Failure(var err) -> switch (supplierRepository.existsByName(supplier.name())) {
case Result.Failure(var err) ->
{ return Result.failure(new SupplierError.RepositoryFailure(err.message())); } { return Result.failure(new SupplierError.RepositoryFailure(err.message())); }
case Success(var exists) -> { case Result.Success(var exists) -> {
if (exists) { if (exists) {
return Result.failure(new SupplierError.SupplierNameAlreadyExists(cmd.name())); return Result.failure(new SupplierError.SupplierNameAlreadyExists(cmd.name()));
} }
} }
} }
Address address; // 4. Speichern
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);
switch (supplierRepository.save(supplier)) { switch (supplierRepository.save(supplier)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new SupplierError.RepositoryFailure(err.message())); } { return Result.failure(new SupplierError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(supplier); return Result.success(supplier);

View file

@ -6,8 +6,6 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class UpdateArticle { public class UpdateArticle {
@ -22,9 +20,9 @@ public class UpdateArticle {
Article article; Article article;
switch (articleRepository.findById(articleId)) { switch (articleRepository.findById(articleId)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ArticleError.RepositoryFailure(err.message())); } { return Result.failure(new ArticleError.RepositoryFailure(err.message())); }
case Success(var opt) -> { case Result.Success(var opt) -> {
if (opt.isEmpty()) { if (opt.isEmpty()) {
return Result.failure(new ArticleError.ArticleNotFound(articleId)); return Result.failure(new ArticleError.ArticleNotFound(articleId));
} }
@ -32,22 +30,16 @@ public class UpdateArticle {
} }
} }
if (cmd.name() != null) { var draft = new ArticleUpdateDraft(cmd.name(), cmd.categoryId());
ArticleName name; switch (article.update(draft)) {
switch (ArticleName.of(cmd.name())) { case Result.Failure(var err) -> { return Result.failure(err); }
case Failure(var msg) -> { return Result.failure(new ArticleError.ValidationFailure(msg)); } case Result.Success(var ignored) -> { }
case Success(var val) -> name = val;
}
article.rename(name);
}
if (cmd.categoryId() != null) {
article.changeCategory(ProductCategoryId.of(cmd.categoryId()));
} }
switch (articleRepository.save(article)) { switch (articleRepository.save(article)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ArticleError.RepositoryFailure(err.message())); } { return Result.failure(new ArticleError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(article); return Result.success(article);

View file

@ -2,12 +2,10 @@ package de.effigenix.application.masterdata;
import de.effigenix.application.masterdata.command.UpdateCustomerCommand; import de.effigenix.application.masterdata.command.UpdateCustomerCommand;
import de.effigenix.domain.masterdata.*; import de.effigenix.domain.masterdata.*;
import de.effigenix.shared.common.*; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class UpdateCustomer { public class UpdateCustomer {
@ -22,9 +20,9 @@ public class UpdateCustomer {
Customer customer; Customer customer;
switch (customerRepository.findById(customerId)) { switch (customerRepository.findById(customerId)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new CustomerError.RepositoryFailure(err.message())); } { return Result.failure(new CustomerError.RepositoryFailure(err.message())); }
case Success(var opt) -> { case Result.Success(var opt) -> {
if (opt.isEmpty()) { if (opt.isEmpty()) {
return Result.failure(new CustomerError.CustomerNotFound(customerId)); return Result.failure(new CustomerError.CustomerNotFound(customerId));
} }
@ -32,40 +30,20 @@ public class UpdateCustomer {
} }
} }
if (cmd.name() != null) { var draft = new CustomerUpdateDraft(
CustomerName name; cmd.name(), cmd.phone(), cmd.email(), cmd.contactPerson(),
switch (CustomerName.of(cmd.name())) { cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(),
case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); } cmd.paymentDueDays(), cmd.paymentDescription()
case Success(var val) -> name = val; );
} switch (customer.update(draft)) {
customer.updateName(name); 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)) { switch (customerRepository.save(customer)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new CustomerError.RepositoryFailure(err.message())); } { return Result.failure(new CustomerError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(customer); return Result.success(customer);

View file

@ -6,8 +6,6 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class UpdateProductCategory { public class UpdateProductCategory {
@ -22,9 +20,9 @@ public class UpdateProductCategory {
ProductCategory category; ProductCategory category;
switch (categoryRepository.findById(categoryId)) { switch (categoryRepository.findById(categoryId)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); }
case Success(var opt) -> { case Result.Success(var opt) -> {
if (opt.isEmpty()) { if (opt.isEmpty()) {
return Result.failure(new ProductCategoryError.CategoryNotFound(categoryId)); return Result.failure(new ProductCategoryError.CategoryNotFound(categoryId));
} }
@ -32,22 +30,16 @@ public class UpdateProductCategory {
} }
} }
if (cmd.name() != null) { var draft = new ProductCategoryUpdateDraft(cmd.name(), cmd.description());
CategoryName name; switch (category.update(draft)) {
switch (CategoryName.of(cmd.name())) { case Result.Failure(var err) -> { return Result.failure(err); }
case Failure(var msg) -> { return Result.failure(new ProductCategoryError.ValidationFailure(msg)); } case Result.Success(var ignored) -> { }
case Success(var val) -> name = val;
}
category.rename(name);
}
if (cmd.description() != null) {
category.updateDescription(cmd.description());
} }
switch (categoryRepository.save(category)) { switch (categoryRepository.save(category)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); } { return Result.failure(new ProductCategoryError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(category); return Result.success(category);

View file

@ -2,12 +2,10 @@ package de.effigenix.application.masterdata;
import de.effigenix.application.masterdata.command.UpdateSupplierCommand; import de.effigenix.application.masterdata.command.UpdateSupplierCommand;
import de.effigenix.domain.masterdata.*; import de.effigenix.domain.masterdata.*;
import de.effigenix.shared.common.*; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static de.effigenix.shared.common.Result.*;
@Transactional @Transactional
public class UpdateSupplier { public class UpdateSupplier {
@ -18,13 +16,14 @@ public class UpdateSupplier {
} }
public Result<SupplierError, Supplier> execute(UpdateSupplierCommand cmd, ActorId performedBy) { public Result<SupplierError, Supplier> execute(UpdateSupplierCommand cmd, ActorId performedBy) {
// 1. Laden
var supplierId = SupplierId.of(cmd.supplierId()); var supplierId = SupplierId.of(cmd.supplierId());
Supplier supplier; Supplier supplier;
switch (supplierRepository.findById(supplierId)) { switch (supplierRepository.findById(supplierId)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new SupplierError.RepositoryFailure(err.message())); } { return Result.failure(new SupplierError.RepositoryFailure(err.message())); }
case Success(var opt) -> { case Result.Success(var opt) -> {
if (opt.isEmpty()) { if (opt.isEmpty()) {
return Result.failure(new SupplierError.SupplierNotFound(supplierId)); return Result.failure(new SupplierError.SupplierNotFound(supplierId));
} }
@ -32,40 +31,22 @@ public class UpdateSupplier {
} }
} }
if (cmd.name() != null) { // 2. Draft bauen + Aggregate delegiert Validierung
SupplierName name; var draft = new SupplierUpdateDraft(
switch (SupplierName.of(cmd.name())) { cmd.name(), cmd.phone(), cmd.email(), cmd.contactPerson(),
case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); } cmd.street(), cmd.houseNumber(), cmd.postalCode(), cmd.city(), cmd.country(),
case Success(var val) -> name = val; cmd.paymentDueDays(), cmd.paymentDescription()
} );
supplier.updateName(name); switch (supplier.update(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
} }
Address address; // 3. Speichern
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);
switch (supplierRepository.save(supplier)) { switch (supplierRepository.save(supplier)) {
case Failure(var err) -> case Result.Failure(var err) ->
{ return Result.failure(new SupplierError.RepositoryFailure(err.message())); } { return Result.failure(new SupplierError.RepositoryFailure(err.message())); }
case Success(var ignored) -> { } case Result.Success(var ignored) -> { }
} }
return Result.success(supplier); return Result.success(supplier);

View file

@ -13,6 +13,6 @@ public record CreateCustomerCommand(
String phone, String phone,
String email, String email,
String contactPerson, String contactPerson,
int paymentDueDays, Integer paymentDueDays,
String paymentDescription String paymentDescription
) {} ) {}

View file

@ -1,15 +1,15 @@
package de.effigenix.application.masterdata.command; package de.effigenix.application.masterdata.command;
public record CreateSupplierCommand( public record CreateSupplierCommand(
String name, String name, // required
String street, String phone, // required
String houseNumber, String email, // optional
String postalCode, String contactPerson, // optional
String city, String street, // optional
String country, String houseNumber, // optional
String phone, String postalCode, // optional
String email, String city, // optional
String contactPerson, String country, // optional
int paymentDueDays, Integer paymentDueDays, // optional
String paymentDescription String paymentDescription // optional
) {} ) {}

View file

@ -11,6 +11,6 @@ public record UpdateCustomerCommand(
String phone, String phone,
String email, String email,
String contactPerson, String contactPerson,
int paymentDueDays, Integer paymentDueDays,
String paymentDescription String paymentDescription
) {} ) {}

View file

@ -2,15 +2,15 @@ package de.effigenix.application.masterdata.command;
public record UpdateSupplierCommand( public record UpdateSupplierCommand(
String supplierId, String supplierId,
String name, String name, // null = nicht ändern
String street, String phone, // null = nicht ändern
String houseNumber, String email, // null = nicht ändern
String postalCode, String contactPerson, // null = nicht ändern
String city, String street, // null = Address nicht ändern
String country, String houseNumber, // null = Address nicht ändern
String phone, String postalCode, // null = Address nicht ändern
String email, String city, // null = Address nicht ändern
String contactPerson, String country, // null = Address nicht ändern
int paymentDueDays, Integer paymentDueDays, // null = PaymentTerms nicht ändern
String paymentDescription String paymentDescription // null = PaymentTerms nicht ändern
) {} ) {}

View file

@ -56,7 +56,7 @@ public class ChangePassword {
// 3. Validate new password // 3. Validate new password
if (!passwordHasher.isValidPassword(cmd.newPassword())) { 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 // 4. Hash new password

View file

@ -44,7 +44,7 @@ public class CreateUser {
public Result<UserError, UserDTO> execute(CreateUserCommand cmd, ActorId performedBy) { public Result<UserError, UserDTO> execute(CreateUserCommand cmd, ActorId performedBy) {
// 1. Validate password // 1. Validate password
if (!passwordHasher.isValidPassword(cmd.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 // 2. Check username uniqueness

View file

@ -3,6 +3,8 @@ package de.effigenix.domain.masterdata;
import de.effigenix.shared.common.Money; import de.effigenix.shared.common.Money;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import static de.effigenix.shared.common.Result.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
@ -49,22 +51,50 @@ public class Article {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
public static Result<ArticleError, Article> create( /**
ArticleName name, * Factory: Erzeugt einen neuen Article aus rohen Eingaben.
ArticleNumber articleNumber, * Orchestriert Validierung aller VOs intern.
ProductCategoryId categoryId, * Alle Felder sind Pflicht.
SalesUnit initialSalesUnit */
) { public static Result<ArticleError, Article> create(ArticleDraft draft) {
if (initialSalesUnit == null) { // 1. ArticleName validieren
return Result.failure(new ArticleError.MinimumSalesUnitRequired()); 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(); var now = LocalDateTime.now();
return Result.success(new Article( return Result.success(new Article(
ArticleId.generate(), ArticleId.generate(),
name, name,
articleNumber, articleNumber,
categoryId, categoryId,
List.of(initialSalesUnit), List.of(salesUnit),
ArticleStatus.ACTIVE, ArticleStatus.ACTIVE,
Set.of(), Set.of(),
now, now,
@ -124,14 +154,22 @@ public class Article {
// ==================== Article Properties ==================== // ==================== Article Properties ====================
public void rename(ArticleName newName) { /**
this.name = newName; * Wendet partielle Updates an. Das Aggregate validiert intern.
touch(); * null-Felder im Draft werden ignoriert.
} */
public Result<ArticleError, Void> update(ArticleUpdateDraft draft) {
public void changeCategory(ProductCategoryId newCategoryId) { if (draft.name() != null) {
this.categoryId = newCategoryId; 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(); touch();
return Result.success(null);
} }
public void activate() { public void activate() {

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -6,6 +6,8 @@ import de.effigenix.shared.common.Money;
import de.effigenix.shared.common.PaymentTerms; import de.effigenix.shared.common.PaymentTerms;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import static de.effigenix.shared.common.Result.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
@ -13,9 +15,11 @@ import java.util.*;
* Aggregate Root for Customer. * Aggregate Root for Customer.
* *
* Invariants: * Invariants:
* 1. Name, CustomerType, billingAddress non-null * 1. Name, CustomerType und billingAddress sind Pflicht
* 2. FrameContract only for B2B customers * 2. ContactInfo (phone) ist Pflicht
* 3. No duplicate ContractLineItems per ArticleId (enforced by FrameContract) * 3. PaymentTerms ist optional
* 4. FrameContract nur für B2B-Kunden
* 5. No duplicate ContractLineItems per ArticleId (enforced by FrameContract)
*/ */
public class Customer { public class Customer {
@ -60,18 +64,47 @@ public class Customer {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
public static Customer create( /**
CustomerName name, * Factory: Erzeugt einen neuen Customer aus rohen Eingaben.
CustomerType type, * Orchestriert Validierung aller VOs intern.
Address billingAddress, * Pflicht: name, type, phone, street, city, country. Optional: alles andere.
ContactInfo contactInfo, */
PaymentTerms paymentTerms public static Result<CustomerError, Customer> 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(); var now = LocalDateTime.now();
return new Customer( return Result.success(new Customer(
CustomerId.generate(), name, type, billingAddress, contactInfo, paymentTerms, CustomerId.generate(), name, draft.type(), billingAddress, contactInfo, paymentTerms,
List.of(), null, Set.of(), CustomerStatus.ACTIVE, now, now List.of(), null, Set.of(), CustomerStatus.ACTIVE, now, now
); ));
} }
public static Customer reconstitute( public static Customer reconstitute(
@ -94,24 +127,41 @@ public class Customer {
// ==================== Business Methods ==================== // ==================== Business Methods ====================
public void updateName(CustomerName newName) { /**
this.name = newName; * Wendet partielle Updates an. Das Aggregate validiert intern.
touch(); * null-Felder im Draft werden ignoriert.
} */
public Result<CustomerError, Void> 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) { if (draft.phone() != null) {
this.billingAddress = newAddress; switch (ContactInfo.create(draft.phone(), draft.email(), draft.contactPerson())) {
touch(); case Failure(var msg) -> { return Result.failure(new CustomerError.ValidationFailure(msg)); }
} case Success(var val) -> this.contactInfo = val;
}
}
public void updateContactInfo(ContactInfo newContactInfo) { if (draft.street() != null || draft.city() != null) {
this.contactInfo = newContactInfo; switch (Address.create(draft.street(), draft.houseNumber(), draft.postalCode(), draft.city(), draft.country())) {
touch(); 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(); touch();
return Result.success(null);
} }
// ==================== Delivery Addresses ==================== // ==================== Delivery Addresses ====================

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -1,5 +1,9 @@
package de.effigenix.domain.masterdata; package de.effigenix.domain.masterdata;
import de.effigenix.shared.common.Result;
import static de.effigenix.shared.common.Result.*;
/** /**
* Mini-Aggregate for product categories. * Mini-Aggregate for product categories.
* DB-based entity, dynamically extendable by users. * DB-based entity, dynamically extendable by users.
@ -16,20 +20,38 @@ public class ProductCategory {
this.description = description; 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<ProductCategoryError, ProductCategory> 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) { public static ProductCategory reconstitute(ProductCategoryId id, CategoryName name, String description) {
return new ProductCategory(id, name, description); return new ProductCategory(id, name, description);
} }
public void rename(CategoryName newName) { /**
this.name = newName; * Wendet partielle Updates an. Das Aggregate validiert intern.
} * null-Felder im Draft werden ignoriert.
*/
public void updateDescription(String newDescription) { public Result<ProductCategoryError, Void> update(ProductCategoryUpdateDraft draft) {
this.description = newDescription; 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; } public ProductCategoryId id() { return id; }

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -1,5 +1,6 @@
package de.effigenix.domain.masterdata; package de.effigenix.domain.masterdata;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.Address; import de.effigenix.shared.common.Address;
import de.effigenix.shared.common.ContactInfo; import de.effigenix.shared.common.ContactInfo;
import de.effigenix.shared.common.PaymentTerms; import de.effigenix.shared.common.PaymentTerms;
@ -9,13 +10,16 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import static de.effigenix.shared.common.Result.*;
/** /**
* Aggregate Root for Supplier. * Aggregate Root for Supplier.
* *
* Invariants: * Invariants:
* 1. Name, Address, PaymentTerms non-null * 1. Name und ContactInfo (phone) sind Pflicht
* 2. No structurally identical certificates (record equality) * 2. Address und PaymentTerms sind optional
* 3. Rating scores 1-5 (enforced by SupplierRating VO) * 3. No structurally identical certificates (record equality)
* 4. Rating scores 1-5 (enforced by SupplierRating VO)
*/ */
public class Supplier { public class Supplier {
@ -54,17 +58,49 @@ public class Supplier {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
public static Supplier create( /**
SupplierName name, * Factory: Erzeugt einen neuen Supplier aus rohen Eingaben.
Address address, * Orchestriert Validierung aller VOs intern.
ContactInfo contactInfo, * Pflicht: name, phone. Optional: alles andere.
PaymentTerms paymentTerms */
) { public static Result<SupplierError, Supplier> 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(); var now = LocalDateTime.now();
return new Supplier( return Result.success(new Supplier(
SupplierId.generate(), name, address, contactInfo, paymentTerms, SupplierId.generate(), name, address, contactInfo, paymentTerms,
List.of(), null, SupplierStatus.ACTIVE, now, now List.of(), null, SupplierStatus.ACTIVE, now, now
); ));
} }
public static Supplier reconstitute( public static Supplier reconstitute(
@ -85,24 +121,41 @@ public class Supplier {
// ==================== Business Methods ==================== // ==================== Business Methods ====================
public void updateName(SupplierName newName) { /**
this.name = newName; * Wendet partielle Updates an. Das Aggregate validiert intern.
touch(); * null-Felder im Draft werden ignoriert.
} */
public Result<SupplierError, Void> 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) { if (draft.phone() != null) {
this.address = newAddress; switch (ContactInfo.create(draft.phone(), draft.email(), draft.contactPerson())) {
touch(); case Failure(var msg) -> { return Result.failure(new SupplierError.ValidationFailure(msg)); }
} case Success(var val) -> this.contactInfo = val;
}
}
public void updateContactInfo(ContactInfo newContactInfo) { if (draft.street() != null || draft.city() != null) {
this.contactInfo = newContactInfo; switch (Address.create(draft.street(), draft.houseNumber(), draft.postalCode(), draft.city(), draft.country())) {
touch(); 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(); touch();
return Result.success(null);
} }
public void addCertificate(QualityCertificate certificate) { public void addCertificate(QualityCertificate certificate) {

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -6,7 +6,6 @@ import de.effigenix.shared.security.ActorId;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -20,7 +19,6 @@ import java.util.UUID;
* Database-backed implementation of AuditLogger. * Database-backed implementation of AuditLogger.
* *
* HACCP/GoBD Compliance: * HACCP/GoBD Compliance:
* - All operations are async (@Async) for performance (don't block business logic)
* - Logs are written to database for durability * - Logs are written to database for durability
* - Logs are immutable after creation * - Logs are immutable after creation
* - Includes IP address and user agent for forensics * - Includes IP address and user agent for forensics
@ -43,7 +41,6 @@ public class DatabaseAuditLogger implements AuditLogger {
} }
@Override @Override
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(AuditEvent event, String entityId, ActorId performedBy) { public void log(AuditEvent event, String entityId, ActorId performedBy) {
try { try {
@ -67,7 +64,6 @@ public class DatabaseAuditLogger implements AuditLogger {
} }
@Override @Override
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(AuditEvent event, String details) { public void log(AuditEvent event, String details) {
try { try {
@ -90,7 +86,6 @@ public class DatabaseAuditLogger implements AuditLogger {
} }
@Override @Override
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(AuditEvent event, ActorId performedBy) { public void log(AuditEvent event, ActorId performedBy) {
try { try {

View file

@ -9,7 +9,7 @@ VALUES (
'00000000-0000-0000-0000-000000000001', -- Fixed UUID for admin '00000000-0000-0000-0000-000000000001', -- Fixed UUID for admin
'admin', 'admin',
'admin@effigenix.com', 'admin@effigenix.com',
'$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYKKHFw3zqm', -- BCrypt hash for "admin123" '$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi', -- BCrypt hash for "admin123"
NULL, -- No branch = global access NULL, -- No branch = global access
'ACTIVE', 'ACTIVE',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,