mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 12:09: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:
parent
0ee7d91528
commit
8b2fd38192
23 changed files with 1311 additions and 3 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package de.effigenix.infrastructure.masterdata.web.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record AssignSupplierRequest(
|
||||||
|
@NotBlank String supplierId
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package de.effigenix.infrastructure.masterdata.web.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CreateProductCategoryRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
String description
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package de.effigenix.infrastructure.masterdata.web.dto;
|
||||||
|
|
||||||
|
public record UpdateArticleRequest(
|
||||||
|
String name,
|
||||||
|
String categoryId
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package de.effigenix.infrastructure.masterdata.web.dto;
|
||||||
|
|
||||||
|
public record UpdateProductCategoryRequest(
|
||||||
|
String name,
|
||||||
|
String description
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue