1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 14:09:34 +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

@ -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<ArticleError, Article> 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);

View file

@ -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<CustomerError, Customer> 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);

View file

@ -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<ProductCategoryError, ProductCategory> 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);

View file

@ -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<SupplierError, Supplier> 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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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<SupplierError, Supplier> 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);

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -44,7 +44,7 @@ public class CreateUser {
public Result<UserError, UserDTO> 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

View file

@ -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<ArticleError, Article> 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<ArticleError, Article> 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<ArticleError, Void> 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() {

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.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<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();
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<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) {
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 ====================

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;
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<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) {
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<ProductCategoryError, Void> 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; }

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;
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<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();
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<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) {
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) {

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 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 {

View file

@ -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,