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

feat(masterdata): Infra-Layer für Article + ProductCategory Aggregate

Liquibase-Migration (005), JPA-Entities, Mapper, Spring-Data-Repos,
Domain-Repo-Adapter, Request-DTOs, Error-Mapper, REST-Controller
(11 Article-Endpoints, 4 Category-Endpoints) und UseCaseConfiguration
für alle 15 Use Cases. GlobalExceptionHandler erweitert.
This commit is contained in:
Sebastian Frick 2026-02-18 13:15:44 +01:00
parent 0ee7d91528
commit 8b2fd38192
23 changed files with 1311 additions and 3 deletions

View file

@ -0,0 +1,93 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.masterdata.*;
import de.effigenix.domain.masterdata.ArticleRepository;
import de.effigenix.domain.masterdata.ProductCategoryRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MasterDataUseCaseConfiguration {
// ==================== Article Use Cases ====================
@Bean
public CreateArticle createArticle(ArticleRepository articleRepository) {
return new CreateArticle(articleRepository);
}
@Bean
public UpdateArticle updateArticle(ArticleRepository articleRepository) {
return new UpdateArticle(articleRepository);
}
@Bean
public GetArticle getArticle(ArticleRepository articleRepository) {
return new GetArticle(articleRepository);
}
@Bean
public ListArticles listArticles(ArticleRepository articleRepository) {
return new ListArticles(articleRepository);
}
@Bean
public ActivateArticle activateArticle(ArticleRepository articleRepository) {
return new ActivateArticle(articleRepository);
}
@Bean
public DeactivateArticle deactivateArticle(ArticleRepository articleRepository) {
return new DeactivateArticle(articleRepository);
}
@Bean
public AddSalesUnit addSalesUnit(ArticleRepository articleRepository) {
return new AddSalesUnit(articleRepository);
}
@Bean
public RemoveSalesUnit removeSalesUnit(ArticleRepository articleRepository) {
return new RemoveSalesUnit(articleRepository);
}
@Bean
public UpdateSalesUnitPrice updateSalesUnitPrice(ArticleRepository articleRepository) {
return new UpdateSalesUnitPrice(articleRepository);
}
@Bean
public AssignSupplier assignSupplier(ArticleRepository articleRepository) {
return new AssignSupplier(articleRepository);
}
@Bean
public RemoveSupplier removeSupplier(ArticleRepository articleRepository) {
return new RemoveSupplier(articleRepository);
}
// ==================== ProductCategory Use Cases ====================
@Bean
public CreateProductCategory createProductCategory(ProductCategoryRepository categoryRepository) {
return new CreateProductCategory(categoryRepository);
}
@Bean
public UpdateProductCategory updateProductCategory(ProductCategoryRepository categoryRepository) {
return new UpdateProductCategory(categoryRepository);
}
@Bean
public ListProductCategories listProductCategories(ProductCategoryRepository categoryRepository) {
return new ListProductCategories(categoryRepository);
}
@Bean
public DeleteProductCategory deleteProductCategory(
ProductCategoryRepository categoryRepository,
ArticleRepository articleRepository
) {
return new DeleteProductCategory(categoryRepository, articleRepository);
}
}

View file

@ -0,0 +1,77 @@
package de.effigenix.infrastructure.masterdata.persistence.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
@Table(name = "articles")
public class ArticleEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "article_number", nullable = false, unique = true, length = 50)
private String articleNumber;
@Column(name = "category_id", nullable = false, length = 36)
private String categoryId;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private List<SalesUnitEntity> salesUnits = new ArrayList<>();
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "article_suppliers", joinColumns = @JoinColumn(name = "article_id"))
@Column(name = "supplier_id")
private Set<String> supplierIds = new HashSet<>();
protected ArticleEntity() {}
public ArticleEntity(String id, String name, String articleNumber, String categoryId,
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.name = name;
this.articleNumber = articleNumber;
this.categoryId = categoryId;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public String getId() { return id; }
public String getName() { return name; }
public String getArticleNumber() { return articleNumber; }
public String getCategoryId() { return categoryId; }
public String getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<SalesUnitEntity> getSalesUnits() { return salesUnits; }
public Set<String> getSupplierIds() { return supplierIds; }
public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setArticleNumber(String articleNumber) { this.articleNumber = articleNumber; }
public void setCategoryId(String categoryId) { this.categoryId = categoryId; }
public void setStatus(String status) { this.status = status; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public void setSalesUnits(List<SalesUnitEntity> salesUnits) { this.salesUnits = salesUnits; }
public void setSupplierIds(Set<String> supplierIds) { this.supplierIds = supplierIds; }
}

View file

@ -0,0 +1,37 @@
package de.effigenix.infrastructure.masterdata.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "product_categories")
public class ProductCategoryEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "name", nullable = false, unique = true, length = 100)
private String name;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
protected ProductCategoryEntity() {}
public ProductCategoryEntity(String id, String name, String description) {
this.id = id;
this.name = name;
this.description = description;
}
public String getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setDescription(String description) { this.description = description; }
}

View file

@ -0,0 +1,56 @@
package de.effigenix.infrastructure.masterdata.persistence.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "sales_units")
public class SalesUnitEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id", nullable = false)
private ArticleEntity article;
@Column(name = "unit", nullable = false, length = 30)
private String unit;
@Column(name = "price_model", nullable = false, length = 30)
private String priceModel;
@Column(name = "price_amount", nullable = false, precision = 19, scale = 2)
private BigDecimal priceAmount;
@Column(name = "price_currency", nullable = false, length = 3)
private String priceCurrency;
protected SalesUnitEntity() {}
public SalesUnitEntity(String id, ArticleEntity article, String unit, String priceModel,
BigDecimal priceAmount, String priceCurrency) {
this.id = id;
this.article = article;
this.unit = unit;
this.priceModel = priceModel;
this.priceAmount = priceAmount;
this.priceCurrency = priceCurrency;
}
public String getId() { return id; }
public ArticleEntity getArticle() { return article; }
public String getUnit() { return unit; }
public String getPriceModel() { return priceModel; }
public BigDecimal getPriceAmount() { return priceAmount; }
public String getPriceCurrency() { return priceCurrency; }
public void setId(String id) { this.id = id; }
public void setArticle(ArticleEntity article) { this.article = article; }
public void setUnit(String unit) { this.unit = unit; }
public void setPriceModel(String priceModel) { this.priceModel = priceModel; }
public void setPriceAmount(BigDecimal priceAmount) { this.priceAmount = priceAmount; }
public void setPriceCurrency(String priceCurrency) { this.priceCurrency = priceCurrency; }
}

View file

@ -0,0 +1,83 @@
package de.effigenix.infrastructure.masterdata.persistence.mapper;
import de.effigenix.domain.masterdata.*;
import de.effigenix.infrastructure.masterdata.persistence.entity.ArticleEntity;
import de.effigenix.infrastructure.masterdata.persistence.entity.SalesUnitEntity;
import de.effigenix.shared.common.Money;
import org.springframework.stereotype.Component;
import java.util.Currency;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class ArticleMapper {
public ArticleEntity toEntity(Article article) {
var entity = new ArticleEntity(
article.id().value(),
article.name().value(),
article.articleNumber().value(),
article.categoryId().value(),
article.status().name(),
article.createdAt(),
article.updatedAt()
);
List<SalesUnitEntity> salesUnitEntities = article.salesUnits().stream()
.map(su -> toSalesUnitEntity(su, entity))
.collect(Collectors.toList());
entity.setSalesUnits(salesUnitEntities);
Set<String> supplierIds = article.supplierReferences().stream()
.map(SupplierId::value)
.collect(Collectors.toSet());
entity.setSupplierIds(supplierIds);
return entity;
}
public Article toDomain(ArticleEntity entity) {
List<SalesUnit> salesUnits = entity.getSalesUnits().stream()
.map(this::toDomainSalesUnit)
.collect(Collectors.toList());
Set<SupplierId> supplierRefs = entity.getSupplierIds().stream()
.map(SupplierId::of)
.collect(Collectors.toSet());
return Article.reconstitute(
ArticleId.of(entity.getId()),
new ArticleName(entity.getName()),
new ArticleNumber(entity.getArticleNumber()),
ProductCategoryId.of(entity.getCategoryId()),
salesUnits,
ArticleStatus.valueOf(entity.getStatus()),
supplierRefs,
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
private SalesUnitEntity toSalesUnitEntity(SalesUnit su, ArticleEntity article) {
return new SalesUnitEntity(
su.id().value(),
article,
su.unit().name(),
su.priceModel().name(),
su.price().amount(),
su.price().currency().getCurrencyCode()
);
}
private SalesUnit toDomainSalesUnit(SalesUnitEntity entity) {
return SalesUnit.reconstitute(
SalesUnitId.of(entity.getId()),
Unit.valueOf(entity.getUnit()),
PriceModel.valueOf(entity.getPriceModel()),
new Money(entity.getPriceAmount(), Currency.getInstance(entity.getPriceCurrency()))
);
}
}

View file

@ -0,0 +1,27 @@
package de.effigenix.infrastructure.masterdata.persistence.mapper;
import de.effigenix.domain.masterdata.CategoryName;
import de.effigenix.domain.masterdata.ProductCategory;
import de.effigenix.domain.masterdata.ProductCategoryId;
import de.effigenix.infrastructure.masterdata.persistence.entity.ProductCategoryEntity;
import org.springframework.stereotype.Component;
@Component
public class ProductCategoryMapper {
public ProductCategoryEntity toEntity(ProductCategory category) {
return new ProductCategoryEntity(
category.id().value(),
category.name().value(),
category.description()
);
}
public ProductCategory toDomain(ProductCategoryEntity entity) {
return ProductCategory.reconstitute(
ProductCategoryId.of(entity.getId()),
new CategoryName(entity.getName()),
entity.getDescription()
);
}
}

View file

@ -0,0 +1,15 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.infrastructure.masterdata.persistence.entity.ArticleEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ArticleJpaRepository extends JpaRepository<ArticleEntity, String> {
List<ArticleEntity> findByCategoryId(String categoryId);
List<ArticleEntity> findByStatus(String status);
boolean existsByArticleNumber(String articleNumber);
}

View file

@ -0,0 +1,103 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.domain.masterdata.*;
import de.effigenix.infrastructure.masterdata.persistence.mapper.ArticleMapper;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
@Transactional(readOnly = true)
public class JpaArticleRepository implements ArticleRepository {
private final ArticleJpaRepository jpaRepository;
private final ArticleMapper mapper;
public JpaArticleRepository(ArticleJpaRepository jpaRepository, ArticleMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<Article>> findById(ArticleId id) {
try {
Optional<Article> result = jpaRepository.findById(id.value())
.map(mapper::toDomain);
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Article>> findAll() {
try {
List<Article> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Article>> findByCategory(ProductCategoryId categoryId) {
try {
List<Article> result = jpaRepository.findByCategoryId(categoryId.value()).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Article>> findByStatus(ArticleStatus status) {
try {
List<Article> result = jpaRepository.findByStatus(status.name()).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(Article article) {
try {
jpaRepository.save(mapper.toEntity(article));
return Result.success(null);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> delete(Article article) {
try {
jpaRepository.deleteById(article.id().value());
return Result.success(null);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByArticleNumber(ArticleNumber articleNumber) {
try {
return Result.success(jpaRepository.existsByArticleNumber(articleNumber.value()));
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
}

View file

@ -0,0 +1,82 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.domain.masterdata.CategoryName;
import de.effigenix.domain.masterdata.ProductCategory;
import de.effigenix.domain.masterdata.ProductCategoryId;
import de.effigenix.domain.masterdata.ProductCategoryRepository;
import de.effigenix.infrastructure.masterdata.persistence.mapper.ProductCategoryMapper;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
@Transactional(readOnly = true)
public class JpaProductCategoryRepository implements ProductCategoryRepository {
private final ProductCategoryJpaRepository jpaRepository;
private final ProductCategoryMapper mapper;
public JpaProductCategoryRepository(ProductCategoryJpaRepository jpaRepository, ProductCategoryMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<ProductCategory>> findById(ProductCategoryId id) {
try {
Optional<ProductCategory> result = jpaRepository.findById(id.value())
.map(mapper::toDomain);
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<ProductCategory>> findAll() {
try {
List<ProductCategory> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(ProductCategory category) {
try {
jpaRepository.save(mapper.toEntity(category));
return Result.success(null);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> delete(ProductCategory category) {
try {
jpaRepository.deleteById(category.id().value());
return Result.success(null);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByName(CategoryName name) {
try {
return Result.success(jpaRepository.existsByName(name.value()));
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
}

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.infrastructure.masterdata.persistence.entity.ProductCategoryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ProductCategoryJpaRepository extends JpaRepository<ProductCategoryEntity, String> {
boolean existsByName(String name);
Optional<ProductCategoryEntity> findByName(String name);
}

View file

@ -0,0 +1,323 @@
package de.effigenix.infrastructure.masterdata.web.controller;
import de.effigenix.application.masterdata.*;
import de.effigenix.application.masterdata.command.*;
import de.effigenix.domain.masterdata.Article;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.domain.masterdata.ArticleStatus;
import de.effigenix.domain.masterdata.ProductCategoryId;
import de.effigenix.domain.masterdata.SalesUnitId;
import de.effigenix.domain.masterdata.SupplierId;
import de.effigenix.infrastructure.masterdata.web.dto.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/articles")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Articles", description = "Article management endpoints")
public class ArticleController {
private static final Logger logger = LoggerFactory.getLogger(ArticleController.class);
private final CreateArticle createArticle;
private final UpdateArticle updateArticle;
private final GetArticle getArticle;
private final ListArticles listArticles;
private final ActivateArticle activateArticle;
private final DeactivateArticle deactivateArticle;
private final AddSalesUnit addSalesUnit;
private final RemoveSalesUnit removeSalesUnit;
private final UpdateSalesUnitPrice updateSalesUnitPrice;
private final AssignSupplier assignSupplier;
private final RemoveSupplier removeSupplier;
public ArticleController(
CreateArticle createArticle,
UpdateArticle updateArticle,
GetArticle getArticle,
ListArticles listArticles,
ActivateArticle activateArticle,
DeactivateArticle deactivateArticle,
AddSalesUnit addSalesUnit,
RemoveSalesUnit removeSalesUnit,
UpdateSalesUnitPrice updateSalesUnitPrice,
AssignSupplier assignSupplier,
RemoveSupplier removeSupplier
) {
this.createArticle = createArticle;
this.updateArticle = updateArticle;
this.getArticle = getArticle;
this.listArticles = listArticles;
this.activateArticle = activateArticle;
this.deactivateArticle = deactivateArticle;
this.addSalesUnit = addSalesUnit;
this.removeSalesUnit = removeSalesUnit;
this.updateSalesUnitPrice = updateSalesUnitPrice;
this.assignSupplier = assignSupplier;
this.removeSupplier = removeSupplier;
}
@PostMapping
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> createArticle(
@Valid @RequestBody CreateArticleRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Creating article: {} by actor: {}", request.articleNumber(), actorId.value());
var cmd = new CreateArticleCommand(
request.name(), request.articleNumber(), request.categoryId(),
request.unit(), request.priceModel(), request.price()
);
var result = createArticle.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Article created: {}", request.articleNumber());
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
}
@GetMapping
public ResponseEntity<List<Article>> listArticles(
@RequestParam(value = "categoryId", required = false) String categoryId,
@RequestParam(value = "status", required = false) ArticleStatus status,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Listing articles by actor: {}", actorId.value());
Result<ArticleError, List<Article>> result;
if (categoryId != null) {
result = listArticles.executeByCategory(ProductCategoryId.of(categoryId));
} else if (status != null) {
result = listArticles.executeByStatus(status);
} else {
result = listArticles.execute();
}
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(result.unsafeGetValue());
}
@GetMapping("/{id}")
public ResponseEntity<Article> getArticle(
@PathVariable("id") String articleId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Getting article: {} by actor: {}", articleId, actorId.value());
var result = getArticle.execute(ArticleId.of(articleId));
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(result.unsafeGetValue());
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> updateArticle(
@PathVariable("id") String articleId,
@Valid @RequestBody UpdateArticleRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Updating article: {} by actor: {}", articleId, actorId.value());
var cmd = new UpdateArticleCommand(articleId, request.name(), request.categoryId());
var result = updateArticle.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Article updated: {}", articleId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/activate")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> activate(
@PathVariable("id") String articleId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Activating article: {} by actor: {}", articleId, actorId.value());
var result = activateArticle.execute(ArticleId.of(articleId), actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Article activated: {}", articleId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/deactivate")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> deactivate(
@PathVariable("id") String articleId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Deactivating article: {} by actor: {}", articleId, actorId.value());
var result = deactivateArticle.execute(ArticleId.of(articleId), actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Article deactivated: {}", articleId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/sales-units")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> addSalesUnit(
@PathVariable("id") String articleId,
@Valid @RequestBody AddSalesUnitRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Adding sales unit to article: {} by actor: {}", articleId, actorId.value());
var cmd = new AddSalesUnitCommand(articleId, request.unit(), request.priceModel(), request.price());
var result = addSalesUnit.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Sales unit added to article: {}", articleId);
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
}
@DeleteMapping("/{id}/sales-units/{suId}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Void> removeSalesUnit(
@PathVariable("id") String articleId,
@PathVariable("suId") String salesUnitId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Removing sales unit {} from article: {} by actor: {}", salesUnitId, articleId, actorId.value());
var cmd = new RemoveSalesUnitCommand(articleId, salesUnitId);
var result = removeSalesUnit.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Sales unit removed from article: {}", articleId);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/sales-units/{suId}/price")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> updateSalesUnitPrice(
@PathVariable("id") String articleId,
@PathVariable("suId") String salesUnitId,
@Valid @RequestBody UpdateSalesUnitPriceRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Updating price of sales unit {} in article: {} by actor: {}", salesUnitId, articleId, actorId.value());
var cmd = new UpdateSalesUnitPriceCommand(articleId, salesUnitId, request.price());
var result = updateSalesUnitPrice.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Sales unit price updated in article: {}", articleId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/suppliers")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Article> assignSupplier(
@PathVariable("id") String articleId,
@Valid @RequestBody AssignSupplierRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Assigning supplier {} to article: {} by actor: {}", request.supplierId(), articleId, actorId.value());
var cmd = new AssignSupplierCommand(articleId, request.supplierId());
var result = assignSupplier.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Supplier assigned to article: {}", articleId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@DeleteMapping("/{id}/suppliers/{supplierId}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Void> removeSupplier(
@PathVariable("id") String articleId,
@PathVariable("supplierId") String supplierId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Removing supplier {} from article: {} by actor: {}", supplierId, articleId, actorId.value());
var cmd = new RemoveSupplierCommand(articleId, supplierId);
var result = removeSupplier.execute(cmd, actorId);
if (result.isFailure()) {
throw new ArticleDomainErrorException(result.unsafeGetError());
}
logger.info("Supplier removed from article: {}", articleId);
return ResponseEntity.noContent().build();
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");
}
return ActorId.of(authentication.getName());
}
public static class ArticleDomainErrorException extends RuntimeException {
private final ArticleError error;
public ArticleDomainErrorException(ArticleError error) {
super(error.message());
this.error = error;
}
public ArticleError getError() {
return error;
}
}
}

View file

@ -0,0 +1,146 @@
package de.effigenix.infrastructure.masterdata.web.controller;
import de.effigenix.application.masterdata.CreateProductCategory;
import de.effigenix.application.masterdata.DeleteProductCategory;
import de.effigenix.application.masterdata.ListProductCategories;
import de.effigenix.application.masterdata.UpdateProductCategory;
import de.effigenix.application.masterdata.command.CreateProductCategoryCommand;
import de.effigenix.application.masterdata.command.UpdateProductCategoryCommand;
import de.effigenix.domain.masterdata.ProductCategory;
import de.effigenix.domain.masterdata.ProductCategoryId;
import de.effigenix.infrastructure.masterdata.web.dto.CreateProductCategoryRequest;
import de.effigenix.infrastructure.masterdata.web.dto.UpdateProductCategoryRequest;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Product Categories", description = "Product category management endpoints")
public class ProductCategoryController {
private static final Logger logger = LoggerFactory.getLogger(ProductCategoryController.class);
private final CreateProductCategory createProductCategory;
private final UpdateProductCategory updateProductCategory;
private final ListProductCategories listProductCategories;
private final DeleteProductCategory deleteProductCategory;
public ProductCategoryController(
CreateProductCategory createProductCategory,
UpdateProductCategory updateProductCategory,
ListProductCategories listProductCategories,
DeleteProductCategory deleteProductCategory
) {
this.createProductCategory = createProductCategory;
this.updateProductCategory = updateProductCategory;
this.listProductCategories = listProductCategories;
this.deleteProductCategory = deleteProductCategory;
}
@PostMapping
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<ProductCategory> createCategory(
@Valid @RequestBody CreateProductCategoryRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Creating product category: {} by actor: {}", request.name(), actorId.value());
var cmd = new CreateProductCategoryCommand(request.name(), request.description());
var result = createProductCategory.execute(cmd, actorId);
if (result.isFailure()) {
throw new ProductCategoryDomainErrorException(result.unsafeGetError());
}
logger.info("Product category created: {}", request.name());
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
}
@GetMapping
public ResponseEntity<List<ProductCategory>> listCategories(Authentication authentication) {
var actorId = extractActorId(authentication);
logger.info("Listing product categories by actor: {}", actorId.value());
var result = listProductCategories.execute();
if (result.isFailure()) {
throw new ProductCategoryDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(result.unsafeGetValue());
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<ProductCategory> updateCategory(
@PathVariable("id") String categoryId,
@Valid @RequestBody UpdateProductCategoryRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Updating product category: {} by actor: {}", categoryId, actorId.value());
var cmd = new UpdateProductCategoryCommand(categoryId, request.name(), request.description());
var result = updateProductCategory.execute(cmd, actorId);
if (result.isFailure()) {
throw new ProductCategoryDomainErrorException(result.unsafeGetError());
}
logger.info("Product category updated: {}", categoryId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Void> deleteCategory(
@PathVariable("id") String categoryId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Deleting product category: {} by actor: {}", categoryId, actorId.value());
var result = deleteProductCategory.execute(ProductCategoryId.of(categoryId), actorId);
if (result.isFailure()) {
throw new ProductCategoryDomainErrorException(result.unsafeGetError());
}
logger.info("Product category deleted: {}", categoryId);
return ResponseEntity.noContent().build();
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");
}
return ActorId.of(authentication.getName());
}
public static class ProductCategoryDomainErrorException extends RuntimeException {
private final de.effigenix.domain.masterdata.ProductCategoryError error;
public ProductCategoryDomainErrorException(de.effigenix.domain.masterdata.ProductCategoryError error) {
super(error.message());
this.error = error;
}
public de.effigenix.domain.masterdata.ProductCategoryError getError() {
return error;
}
}
}

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.PriceModel;
import de.effigenix.domain.masterdata.Unit;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record AddSalesUnitRequest(
@NotNull Unit unit,
@NotNull PriceModel priceModel,
@NotNull BigDecimal price
) {}

View file

@ -0,0 +1,7 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import jakarta.validation.constraints.NotBlank;
public record AssignSupplierRequest(
@NotBlank String supplierId
) {}

View file

@ -0,0 +1,17 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.PriceModel;
import de.effigenix.domain.masterdata.Unit;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record CreateArticleRequest(
@NotBlank String name,
@NotBlank String articleNumber,
@NotBlank String categoryId,
@NotNull Unit unit,
@NotNull PriceModel priceModel,
@NotNull BigDecimal price
) {}

View file

@ -0,0 +1,8 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateProductCategoryRequest(
@NotBlank String name,
String description
) {}

View file

@ -0,0 +1,6 @@
package de.effigenix.infrastructure.masterdata.web.dto;
public record UpdateArticleRequest(
String name,
String categoryId
) {}

View file

@ -0,0 +1,6 @@
package de.effigenix.infrastructure.masterdata.web.dto;
public record UpdateProductCategoryRequest(
String name,
String description
) {}

View file

@ -0,0 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record UpdateSalesUnitPriceRequest(
@NotNull BigDecimal price
) {}

View file

@ -0,0 +1,36 @@
package de.effigenix.infrastructure.masterdata.web.exception;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError;
public final class MasterDataErrorHttpStatusMapper {
private MasterDataErrorHttpStatusMapper() {}
public static int toHttpStatus(ArticleError error) {
return switch (error) {
case ArticleError.ArticleNotFound e -> 404;
case ArticleError.SalesUnitNotFound e -> 404;
case ArticleError.ArticleNumberAlreadyExists e -> 409;
case ArticleError.DuplicateSalesUnitType e -> 409;
case ArticleError.MinimumSalesUnitRequired e -> 400;
case ArticleError.InvalidPriceModelCombination e -> 400;
case ArticleError.InvalidPrice e -> 400;
case ArticleError.ValidationFailure e -> 400;
case ArticleError.Unauthorized e -> 403;
case ArticleError.RepositoryFailure e -> 500;
};
}
public static int toHttpStatus(ProductCategoryError error) {
return switch (error) {
case ProductCategoryError.CategoryNotFound e -> 404;
case ProductCategoryError.CategoryNameAlreadyExists e -> 409;
case ProductCategoryError.CategoryInUse e -> 409;
case ProductCategoryError.InvalidCategoryName e -> 400;
case ProductCategoryError.ValidationFailure e -> 400;
case ProductCategoryError.Unauthorized e -> 403;
case ProductCategoryError.RepositoryFailure e -> 500;
};
}
}

View file

@ -1,6 +1,11 @@
package de.effigenix.infrastructure.usermanagement.web.exception; package de.effigenix.infrastructure.usermanagement.web.exception;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError;
import de.effigenix.domain.usermanagement.UserError; import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpStatusMapper;
import de.effigenix.infrastructure.usermanagement.web.controller.AuthController; import de.effigenix.infrastructure.usermanagement.web.controller.AuthController;
import de.effigenix.infrastructure.usermanagement.web.controller.UserController; import de.effigenix.infrastructure.usermanagement.web.controller.UserController;
import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse;
@ -92,9 +97,43 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(errorResponse); return ResponseEntity.status(status).body(errorResponse);
} }
// Note: UserError and ApplicationError are interfaces, not Throwable @ExceptionHandler(ArticleController.ArticleDomainErrorException.class)
// They are wrapped in RuntimeException subclasses (AuthenticationFailedException, DomainErrorException) public ResponseEntity<ErrorResponse> handleArticleDomainError(
// which are then caught by the handlers above ArticleController.ArticleDomainErrorException ex,
HttpServletRequest request
) {
ArticleError error = ex.getError();
int status = MasterDataErrorHttpStatusMapper.toHttpStatus(error);
logger.warn("Article domain error: {} - {}", error.code(), error.message());
ErrorResponse errorResponse = ErrorResponse.from(
error.code(),
error.message(),
status,
request.getRequestURI()
);
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(ProductCategoryController.ProductCategoryDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleProductCategoryDomainError(
ProductCategoryController.ProductCategoryDomainErrorException ex,
HttpServletRequest request
) {
ProductCategoryError error = ex.getError();
int status = MasterDataErrorHttpStatusMapper.toHttpStatus(error);
logger.warn("ProductCategory domain error: {} - {}", error.code(), error.message());
ErrorResponse errorResponse = ErrorResponse.from(
error.code(),
error.message(),
status,
request.getRequestURI()
);
return ResponseEntity.status(status).body(errorResponse);
}
/** /**
* Handles validation errors from @Valid annotations. * Handles validation errors from @Valid annotations.

View file

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="005-create-product-categories-table" author="effigenix">
<createTable tableName="product_categories">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(100)">
<constraints nullable="false" unique="true"/>
</column>
<column name="description" type="TEXT"/>
</createTable>
<createIndex tableName="product_categories" indexName="idx_product_categories_name">
<column name="name"/>
</createIndex>
</changeSet>
<changeSet id="005-create-articles-table" author="effigenix">
<createTable tableName="articles">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="article_number" type="VARCHAR(50)">
<constraints nullable="false" unique="true"/>
</column>
<column name="category_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="articles" baseColumnNames="category_id"
referencedTableName="product_categories" referencedColumnNames="id"
constraintName="fk_articles_category"/>
<sql>
ALTER TABLE articles ADD CONSTRAINT chk_article_status CHECK (status IN ('ACTIVE', 'INACTIVE'));
</sql>
<createIndex tableName="articles" indexName="idx_articles_article_number">
<column name="article_number"/>
</createIndex>
<createIndex tableName="articles" indexName="idx_articles_category_id">
<column name="category_id"/>
</createIndex>
<createIndex tableName="articles" indexName="idx_articles_status">
<column name="status"/>
</createIndex>
</changeSet>
<changeSet id="005-create-sales-units-table" author="effigenix">
<createTable tableName="sales_units">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="article_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="unit" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="price_model" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="price_amount" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="price_currency" type="VARCHAR(3)">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="sales_units" baseColumnNames="article_id"
referencedTableName="articles" referencedColumnNames="id"
constraintName="fk_sales_units_article" onDelete="CASCADE"/>
<createIndex tableName="sales_units" indexName="idx_sales_units_article_id">
<column name="article_id"/>
</createIndex>
</changeSet>
<changeSet id="005-create-article-suppliers-table" author="effigenix">
<createTable tableName="article_suppliers">
<column name="article_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="supplier_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
</createTable>
<addPrimaryKey tableName="article_suppliers" columnNames="article_id, supplier_id"/>
<addForeignKeyConstraint baseTableName="article_suppliers" baseColumnNames="article_id"
referencedTableName="articles" referencedColumnNames="id"
constraintName="fk_article_suppliers_article" onDelete="CASCADE"/>
<createIndex tableName="article_suppliers" indexName="idx_article_suppliers_article_id">
<column name="article_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -9,5 +9,6 @@
<include file="db/changelog/changes/002-seed-roles-and-permissions.xml"/> <include file="db/changelog/changes/002-seed-roles-and-permissions.xml"/>
<include file="db/changelog/changes/003-create-audit-logs-table.xml"/> <include file="db/changelog/changes/003-create-audit-logs-table.xml"/>
<include file="db/changelog/changes/004-seed-admin-user.xml"/> <include file="db/changelog/changes/004-seed-admin-user.xml"/>
<include file="db/changelog/changes/005-create-masterdata-schema.xml"/>
</databaseChangeLog> </databaseChangeLog>