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

feat(masterdata): Infra-Layer für Customer Aggregate

Liquibase-Migration (007), JPA-Entities (Customer, FrameContract,
DeliveryAddress, ContractLineItem), Mapper, Repository-Adapter,
5 Request-DTOs, CustomerController (11 Endpoints), Error-Mapping
und 11 Use-Case-Beans in MasterDataUseCaseConfiguration.
This commit is contained in:
Sebastian Frick 2026-02-18 13:30:13 +01:00
parent 6ec07e7b34
commit 797f435a49
19 changed files with 1243 additions and 0 deletions

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.masterdata.*;
import de.effigenix.domain.masterdata.ArticleRepository;
import de.effigenix.domain.masterdata.CustomerRepository;
import de.effigenix.domain.masterdata.ProductCategoryRepository;
import de.effigenix.domain.masterdata.SupplierRepository;
import org.springframework.context.annotation.Bean;
@ -138,4 +139,61 @@ public class MasterDataUseCaseConfiguration {
public RemoveCertificate removeCertificate(SupplierRepository supplierRepository) {
return new RemoveCertificate(supplierRepository);
}
// ==================== Customer Use Cases ====================
@Bean
public CreateCustomer createCustomer(CustomerRepository customerRepository) {
return new CreateCustomer(customerRepository);
}
@Bean
public UpdateCustomer updateCustomer(CustomerRepository customerRepository) {
return new UpdateCustomer(customerRepository);
}
@Bean
public GetCustomer getCustomer(CustomerRepository customerRepository) {
return new GetCustomer(customerRepository);
}
@Bean
public ListCustomers listCustomers(CustomerRepository customerRepository) {
return new ListCustomers(customerRepository);
}
@Bean
public ActivateCustomer activateCustomer(CustomerRepository customerRepository) {
return new ActivateCustomer(customerRepository);
}
@Bean
public DeactivateCustomer deactivateCustomer(CustomerRepository customerRepository) {
return new DeactivateCustomer(customerRepository);
}
@Bean
public AddDeliveryAddress addDeliveryAddress(CustomerRepository customerRepository) {
return new AddDeliveryAddress(customerRepository);
}
@Bean
public RemoveDeliveryAddress removeDeliveryAddress(CustomerRepository customerRepository) {
return new RemoveDeliveryAddress(customerRepository);
}
@Bean
public SetFrameContract setFrameContract(CustomerRepository customerRepository) {
return new SetFrameContract(customerRepository);
}
@Bean
public RemoveFrameContract removeFrameContract(CustomerRepository customerRepository) {
return new RemoveFrameContract(customerRepository);
}
@Bean
public SetPreferences setPreferences(CustomerRepository customerRepository) {
return new SetPreferences(customerRepository);
}
}

View file

@ -0,0 +1,42 @@
package de.effigenix.infrastructure.masterdata.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
@Embeddable
public class ContractLineItemEmbeddable {
@Column(name = "article_id", nullable = false, length = 36)
private String articleId;
@Column(name = "agreed_price_amount", nullable = false, precision = 19, scale = 2)
private BigDecimal agreedPriceAmount;
@Column(name = "agreed_price_currency", nullable = false, length = 3)
private String agreedPriceCurrency;
@Column(name = "agreed_quantity", precision = 19, scale = 4)
private BigDecimal agreedQuantity;
@Column(name = "unit", length = 30)
private String unit;
protected ContractLineItemEmbeddable() {}
public ContractLineItemEmbeddable(String articleId, BigDecimal agreedPriceAmount,
String agreedPriceCurrency, BigDecimal agreedQuantity, String unit) {
this.articleId = articleId;
this.agreedPriceAmount = agreedPriceAmount;
this.agreedPriceCurrency = agreedPriceCurrency;
this.agreedQuantity = agreedQuantity;
this.unit = unit;
}
public String getArticleId() { return articleId; }
public BigDecimal getAgreedPriceAmount() { return agreedPriceAmount; }
public String getAgreedPriceCurrency() { return agreedPriceCurrency; }
public BigDecimal getAgreedQuantity() { return agreedQuantity; }
public String getUnit() { return unit; }
}

View file

@ -0,0 +1,119 @@
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 = "customers")
public class CustomerEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "name", nullable = false, unique = true, length = 200)
private String name;
@Column(name = "type", nullable = false, length = 10)
private String type;
@Column(name = "phone", length = 50)
private String phone;
@Column(name = "email", length = 255)
private String email;
@Column(name = "contact_person", length = 200)
private String contactPerson;
@Column(name = "billing_street", nullable = false, length = 200)
private String billingStreet;
@Column(name = "billing_house_number", length = 20)
private String billingHouseNumber;
@Column(name = "billing_postal_code", nullable = false, length = 20)
private String billingPostalCode;
@Column(name = "billing_city", nullable = false, length = 100)
private String billingCity;
@Column(name = "billing_country", nullable = false, length = 2)
private String billingCountry;
@Column(name = "payment_due_days")
private Integer paymentDueDays;
@Column(name = "payment_description", length = 500)
private String paymentDescription;
@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;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "delivery_addresses", joinColumns = @JoinColumn(name = "customer_id"))
private List<DeliveryAddressEmbeddable> deliveryAddresses = new ArrayList<>();
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "customer_preferences", joinColumns = @JoinColumn(name = "customer_id"))
@Column(name = "preference")
@Enumerated(EnumType.STRING)
private Set<de.effigenix.domain.masterdata.CustomerPreference> preferences = new HashSet<>();
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(name = "id", referencedColumnName = "customer_id", insertable = false, updatable = false)
private FrameContractEntity frameContract;
public CustomerEntity() {}
public String getId() { return id; }
public String getName() { return name; }
public String getType() { return type; }
public String getPhone() { return phone; }
public String getEmail() { return email; }
public String getContactPerson() { return contactPerson; }
public String getBillingStreet() { return billingStreet; }
public String getBillingHouseNumber() { return billingHouseNumber; }
public String getBillingPostalCode() { return billingPostalCode; }
public String getBillingCity() { return billingCity; }
public String getBillingCountry() { return billingCountry; }
public Integer getPaymentDueDays() { return paymentDueDays; }
public String getPaymentDescription() { return paymentDescription; }
public String getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<DeliveryAddressEmbeddable> getDeliveryAddresses() { return deliveryAddresses; }
public Set<de.effigenix.domain.masterdata.CustomerPreference> getPreferences() { return preferences; }
public FrameContractEntity getFrameContract() { return frameContract; }
public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setType(String type) { this.type = type; }
public void setPhone(String phone) { this.phone = phone; }
public void setEmail(String email) { this.email = email; }
public void setContactPerson(String contactPerson) { this.contactPerson = contactPerson; }
public void setBillingStreet(String billingStreet) { this.billingStreet = billingStreet; }
public void setBillingHouseNumber(String billingHouseNumber) { this.billingHouseNumber = billingHouseNumber; }
public void setBillingPostalCode(String billingPostalCode) { this.billingPostalCode = billingPostalCode; }
public void setBillingCity(String billingCity) { this.billingCity = billingCity; }
public void setBillingCountry(String billingCountry) { this.billingCountry = billingCountry; }
public void setPaymentDueDays(Integer paymentDueDays) { this.paymentDueDays = paymentDueDays; }
public void setPaymentDescription(String paymentDescription) { this.paymentDescription = paymentDescription; }
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 setDeliveryAddresses(List<DeliveryAddressEmbeddable> deliveryAddresses) { this.deliveryAddresses = deliveryAddresses; }
public void setPreferences(Set<de.effigenix.domain.masterdata.CustomerPreference> preferences) { this.preferences = preferences; }
public void setFrameContract(FrameContractEntity frameContract) { this.frameContract = frameContract; }
}

View file

@ -0,0 +1,56 @@
package de.effigenix.infrastructure.masterdata.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
@Embeddable
public class DeliveryAddressEmbeddable {
@Column(name = "label", length = 100)
private String label;
@Column(name = "street", nullable = false, length = 200)
private String street;
@Column(name = "house_number", length = 20)
private String houseNumber;
@Column(name = "postal_code", nullable = false, length = 20)
private String postalCode;
@Column(name = "city", nullable = false, length = 100)
private String city;
@Column(name = "country", nullable = false, length = 2)
private String country;
@Column(name = "contact_person", length = 200)
private String contactPerson;
@Column(name = "delivery_notes", length = 500)
private String deliveryNotes;
protected DeliveryAddressEmbeddable() {}
public DeliveryAddressEmbeddable(String label, String street, String houseNumber,
String postalCode, String city, String country,
String contactPerson, String deliveryNotes) {
this.label = label;
this.street = street;
this.houseNumber = houseNumber;
this.postalCode = postalCode;
this.city = city;
this.country = country;
this.contactPerson = contactPerson;
this.deliveryNotes = deliveryNotes;
}
public String getLabel() { return label; }
public String getStreet() { return street; }
public String getHouseNumber() { return houseNumber; }
public String getPostalCode() { return postalCode; }
public String getCity() { return city; }
public String getCountry() { return country; }
public String getContactPerson() { return contactPerson; }
public String getDeliveryNotes() { return deliveryNotes; }
}

View file

@ -0,0 +1,57 @@
package de.effigenix.infrastructure.masterdata.persistence.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "frame_contracts")
public class FrameContractEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "customer_id", nullable = false, unique = true, length = 36)
private String customerId;
@Column(name = "valid_from")
private LocalDate validFrom;
@Column(name = "valid_until")
private LocalDate validUntil;
@Column(name = "delivery_rhythm", nullable = false, length = 20)
private String deliveryRhythm;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "contract_line_items", joinColumns = @JoinColumn(name = "frame_contract_id"))
private List<ContractLineItemEmbeddable> lineItems = new ArrayList<>();
protected FrameContractEntity() {}
public FrameContractEntity(String id, String customerId, LocalDate validFrom,
LocalDate validUntil, String deliveryRhythm) {
this.id = id;
this.customerId = customerId;
this.validFrom = validFrom;
this.validUntil = validUntil;
this.deliveryRhythm = deliveryRhythm;
}
public String getId() { return id; }
public String getCustomerId() { return customerId; }
public LocalDate getValidFrom() { return validFrom; }
public LocalDate getValidUntil() { return validUntil; }
public String getDeliveryRhythm() { return deliveryRhythm; }
public List<ContractLineItemEmbeddable> getLineItems() { return lineItems; }
public void setId(String id) { this.id = id; }
public void setCustomerId(String customerId) { this.customerId = customerId; }
public void setValidFrom(LocalDate validFrom) { this.validFrom = validFrom; }
public void setValidUntil(LocalDate validUntil) { this.validUntil = validUntil; }
public void setDeliveryRhythm(String deliveryRhythm) { this.deliveryRhythm = deliveryRhythm; }
public void setLineItems(List<ContractLineItemEmbeddable> lineItems) { this.lineItems = lineItems; }
}

View file

@ -0,0 +1,138 @@
package de.effigenix.infrastructure.masterdata.persistence.mapper;
import de.effigenix.domain.masterdata.*;
import de.effigenix.infrastructure.masterdata.persistence.entity.*;
import de.effigenix.shared.common.Address;
import de.effigenix.shared.common.ContactInfo;
import de.effigenix.shared.common.Money;
import de.effigenix.shared.common.PaymentTerms;
import org.springframework.stereotype.Component;
import java.util.Currency;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class CustomerMapper {
public CustomerEntity toEntity(Customer customer) {
var entity = new CustomerEntity();
entity.setId(customer.id().value());
entity.setName(customer.name().value());
entity.setType(customer.type().name());
var contact = customer.contactInfo();
if (contact != null) {
entity.setPhone(contact.phone());
entity.setEmail(contact.email());
entity.setContactPerson(contact.contactPerson());
}
var billing = customer.billingAddress();
entity.setBillingStreet(billing.street());
entity.setBillingHouseNumber(billing.houseNumber());
entity.setBillingPostalCode(billing.postalCode());
entity.setBillingCity(billing.city());
entity.setBillingCountry(billing.country());
var terms = customer.paymentTerms();
if (terms != null) {
entity.setPaymentDueDays(terms.paymentDueDays());
entity.setPaymentDescription(terms.description());
}
entity.setStatus(customer.status().name());
entity.setCreatedAt(customer.createdAt());
entity.setUpdatedAt(customer.updatedAt());
entity.setDeliveryAddresses(customer.deliveryAddresses().stream()
.map(da -> new DeliveryAddressEmbeddable(
da.label(),
da.address().street(), da.address().houseNumber(),
da.address().postalCode(), da.address().city(), da.address().country(),
da.contactPerson(), da.deliveryNotes()))
.collect(Collectors.toList()));
entity.setPreferences(customer.preferences());
// FrameContract is saved separately not via the @OneToOne mapping
// (handled in JpaCustomerRepository)
return entity;
}
public Customer toDomain(CustomerEntity entity, FrameContractEntity fcEntity) {
Address billingAddress = new Address(
entity.getBillingStreet(), entity.getBillingHouseNumber(),
entity.getBillingPostalCode(), entity.getBillingCity(), entity.getBillingCountry()
);
ContactInfo contactInfo = new ContactInfo(
entity.getPhone(), entity.getEmail(), entity.getContactPerson()
);
PaymentTerms paymentTerms = null;
if (entity.getPaymentDueDays() != null) {
paymentTerms = new PaymentTerms(entity.getPaymentDueDays(), entity.getPaymentDescription());
}
List<DeliveryAddress> deliveryAddresses = entity.getDeliveryAddresses().stream()
.map(da -> new DeliveryAddress(
da.getLabel(),
new Address(da.getStreet(), da.getHouseNumber(),
da.getPostalCode(), da.getCity(), da.getCountry()),
da.getContactPerson(), da.getDeliveryNotes()))
.collect(Collectors.toList());
FrameContract frameContract = null;
if (fcEntity != null) {
List<ContractLineItem> lineItems = fcEntity.getLineItems().stream()
.map(li -> new ContractLineItem(
ArticleId.of(li.getArticleId()),
new Money(li.getAgreedPriceAmount(), Currency.getInstance(li.getAgreedPriceCurrency())),
li.getAgreedQuantity(),
li.getUnit() != null ? Unit.valueOf(li.getUnit()) : null))
.collect(Collectors.toList());
frameContract = FrameContract.reconstitute(
FrameContractId.of(fcEntity.getId()),
fcEntity.getValidFrom(),
fcEntity.getValidUntil(),
DeliveryRhythm.valueOf(fcEntity.getDeliveryRhythm()),
lineItems
);
}
return Customer.reconstitute(
CustomerId.of(entity.getId()),
new CustomerName(entity.getName()),
CustomerType.valueOf(entity.getType()),
billingAddress,
contactInfo,
paymentTerms,
deliveryAddresses,
frameContract,
entity.getPreferences(),
CustomerStatus.valueOf(entity.getStatus()),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
public FrameContractEntity toFrameContractEntity(FrameContract fc, String customerId) {
var entity = new FrameContractEntity(
fc.id().value(), customerId,
fc.validFrom(), fc.validUntil(),
fc.deliveryRhythm().name()
);
entity.setLineItems(fc.lineItems().stream()
.map(li -> new ContractLineItemEmbeddable(
li.articleId().value(),
li.agreedPrice().amount(),
li.agreedPrice().currency().getCurrencyCode(),
li.agreedQuantity(),
li.unit() != null ? li.unit().name() : null))
.collect(Collectors.toList()));
return entity;
}
}

View file

@ -0,0 +1,15 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.infrastructure.masterdata.persistence.entity.CustomerEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CustomerJpaRepository extends JpaRepository<CustomerEntity, String> {
List<CustomerEntity> findByType(String type);
List<CustomerEntity> findByStatus(String status);
boolean existsByName(String name);
}

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.infrastructure.masterdata.persistence.entity.FrameContractEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FrameContractJpaRepository extends JpaRepository<FrameContractEntity, String> {
Optional<FrameContractEntity> findByCustomerId(String customerId);
void deleteByCustomerId(String customerId);
}

View file

@ -0,0 +1,129 @@
package de.effigenix.infrastructure.masterdata.persistence.repository;
import de.effigenix.domain.masterdata.*;
import de.effigenix.infrastructure.masterdata.persistence.entity.FrameContractEntity;
import de.effigenix.infrastructure.masterdata.persistence.mapper.CustomerMapper;
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 JpaCustomerRepository implements CustomerRepository {
private final CustomerJpaRepository jpaRepository;
private final FrameContractJpaRepository frameContractJpaRepository;
private final CustomerMapper mapper;
public JpaCustomerRepository(CustomerJpaRepository jpaRepository,
FrameContractJpaRepository frameContractJpaRepository,
CustomerMapper mapper) {
this.jpaRepository = jpaRepository;
this.frameContractJpaRepository = frameContractJpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<Customer>> findById(CustomerId id) {
try {
var entityOpt = jpaRepository.findById(id.value());
if (entityOpt.isEmpty()) {
return Result.success(Optional.empty());
}
var fcEntity = frameContractJpaRepository.findByCustomerId(id.value()).orElse(null);
return Result.success(Optional.of(mapper.toDomain(entityOpt.get(), fcEntity)));
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Customer>> findAll() {
try {
List<Customer> result = jpaRepository.findAll().stream()
.map(entity -> {
var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null);
return mapper.toDomain(entity, fc);
})
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Customer>> findByType(CustomerType type) {
try {
List<Customer> result = jpaRepository.findByType(type.name()).stream()
.map(entity -> {
var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null);
return mapper.toDomain(entity, fc);
})
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Customer>> findByStatus(CustomerStatus status) {
try {
List<Customer> result = jpaRepository.findByStatus(status.name()).stream()
.map(entity -> {
var fc = frameContractJpaRepository.findByCustomerId(entity.getId()).orElse(null);
return mapper.toDomain(entity, fc);
})
.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(Customer customer) {
try {
jpaRepository.save(mapper.toEntity(customer));
// Handle FrameContract separately
frameContractJpaRepository.deleteByCustomerId(customer.id().value());
if (customer.frameContract() != null) {
var fcEntity = mapper.toFrameContractEntity(customer.frameContract(), customer.id().value());
frameContractJpaRepository.save(fcEntity);
}
return Result.success(null);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> delete(Customer customer) {
try {
frameContractJpaRepository.deleteByCustomerId(customer.id().value());
jpaRepository.deleteById(customer.id().value());
return Result.success(null);
} catch (Exception e) {
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByName(CustomerName 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,342 @@
package de.effigenix.infrastructure.masterdata.web.controller;
import de.effigenix.application.masterdata.*;
import de.effigenix.application.masterdata.command.*;
import de.effigenix.domain.masterdata.Customer;
import de.effigenix.domain.masterdata.CustomerError;
import de.effigenix.domain.masterdata.CustomerId;
import de.effigenix.domain.masterdata.CustomerStatus;
import de.effigenix.domain.masterdata.CustomerType;
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;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/customers")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Customers", description = "Customer management endpoints")
public class CustomerController {
private static final Logger logger = LoggerFactory.getLogger(CustomerController.class);
private final CreateCustomer createCustomer;
private final UpdateCustomer updateCustomer;
private final GetCustomer getCustomer;
private final ListCustomers listCustomers;
private final ActivateCustomer activateCustomer;
private final DeactivateCustomer deactivateCustomer;
private final AddDeliveryAddress addDeliveryAddress;
private final RemoveDeliveryAddress removeDeliveryAddress;
private final SetFrameContract setFrameContract;
private final RemoveFrameContract removeFrameContract;
private final SetPreferences setPreferences;
public CustomerController(
CreateCustomer createCustomer,
UpdateCustomer updateCustomer,
GetCustomer getCustomer,
ListCustomers listCustomers,
ActivateCustomer activateCustomer,
DeactivateCustomer deactivateCustomer,
AddDeliveryAddress addDeliveryAddress,
RemoveDeliveryAddress removeDeliveryAddress,
SetFrameContract setFrameContract,
RemoveFrameContract removeFrameContract,
SetPreferences setPreferences
) {
this.createCustomer = createCustomer;
this.updateCustomer = updateCustomer;
this.getCustomer = getCustomer;
this.listCustomers = listCustomers;
this.activateCustomer = activateCustomer;
this.deactivateCustomer = deactivateCustomer;
this.addDeliveryAddress = addDeliveryAddress;
this.removeDeliveryAddress = removeDeliveryAddress;
this.setFrameContract = setFrameContract;
this.removeFrameContract = removeFrameContract;
this.setPreferences = setPreferences;
}
@PostMapping
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> createCustomer(
@Valid @RequestBody CreateCustomerRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Creating customer: {} by actor: {}", request.name(), actorId.value());
var cmd = new CreateCustomerCommand(
request.name(), request.type(),
request.street(), request.houseNumber(), request.postalCode(),
request.city(), request.country(),
request.phone(), request.email(), request.contactPerson(),
request.paymentDueDays(), request.paymentDescription()
);
var result = createCustomer.execute(cmd, actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Customer created: {}", request.name());
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
}
@GetMapping
public ResponseEntity<List<Customer>> listCustomers(
@RequestParam(value = "type", required = false) CustomerType type,
@RequestParam(value = "status", required = false) CustomerStatus status,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Listing customers by actor: {}", actorId.value());
Result<CustomerError, List<Customer>> result;
if (type != null) {
result = listCustomers.executeByType(type);
} else if (status != null) {
result = listCustomers.executeByStatus(status);
} else {
result = listCustomers.execute();
}
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(result.unsafeGetValue());
}
@GetMapping("/{id}")
public ResponseEntity<Customer> getCustomer(
@PathVariable("id") String customerId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Getting customer: {} by actor: {}", customerId, actorId.value());
var result = getCustomer.execute(CustomerId.of(customerId));
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(result.unsafeGetValue());
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> updateCustomer(
@PathVariable("id") String customerId,
@Valid @RequestBody UpdateCustomerRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Updating customer: {} by actor: {}", customerId, actorId.value());
var cmd = new UpdateCustomerCommand(
customerId,
request.name(),
request.street(), request.houseNumber(), request.postalCode(),
request.city(), request.country(),
request.phone(), request.email(), request.contactPerson(),
request.paymentDueDays(), request.paymentDescription()
);
var result = updateCustomer.execute(cmd, actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Customer updated: {}", customerId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/activate")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> activate(
@PathVariable("id") String customerId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Activating customer: {} by actor: {}", customerId, actorId.value());
var result = activateCustomer.execute(CustomerId.of(customerId), actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Customer activated: {}", customerId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/deactivate")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> deactivate(
@PathVariable("id") String customerId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Deactivating customer: {} by actor: {}", customerId, actorId.value());
var result = deactivateCustomer.execute(CustomerId.of(customerId), actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Customer deactivated: {}", customerId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@PostMapping("/{id}/delivery-addresses")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> addDeliveryAddress(
@PathVariable("id") String customerId,
@Valid @RequestBody AddDeliveryAddressRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Adding delivery address to customer: {} by actor: {}", customerId, actorId.value());
var cmd = new AddDeliveryAddressCommand(
customerId, request.label(),
request.street(), request.houseNumber(), request.postalCode(),
request.city(), request.country(),
request.contactPerson(), request.deliveryNotes()
);
var result = addDeliveryAddress.execute(cmd, actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Delivery address added to customer: {}", customerId);
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
}
@DeleteMapping("/{id}/delivery-addresses/{label}")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Void> removeDeliveryAddress(
@PathVariable("id") String customerId,
@PathVariable("label") String label,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Removing delivery address '{}' from customer: {} by actor: {}", label, customerId, actorId.value());
var cmd = new RemoveDeliveryAddressCommand(customerId, label);
var result = removeDeliveryAddress.execute(cmd, actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Delivery address removed from customer: {}", customerId);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/frame-contract")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> setFrameContract(
@PathVariable("id") String customerId,
@Valid @RequestBody SetFrameContractRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Setting frame contract for customer: {} by actor: {}", customerId, actorId.value());
var lineItems = request.lineItems().stream()
.map(li -> new SetFrameContractCommand.LineItem(
li.articleId(), li.agreedPrice(), li.agreedQuantity(), li.unit()))
.collect(Collectors.toList());
var cmd = new SetFrameContractCommand(
customerId, request.validFrom(), request.validUntil(),
request.rhythm(), lineItems
);
var result = setFrameContract.execute(cmd, actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Frame contract set for customer: {}", customerId);
return ResponseEntity.ok(result.unsafeGetValue());
}
@DeleteMapping("/{id}/frame-contract")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Void> removeFrameContract(
@PathVariable("id") String customerId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Removing frame contract from customer: {} by actor: {}", customerId, actorId.value());
var result = removeFrameContract.execute(CustomerId.of(customerId), actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Frame contract removed from customer: {}", customerId);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/preferences")
@PreAuthorize("hasAuthority('MASTERDATA_WRITE')")
public ResponseEntity<Customer> setPreferences(
@PathVariable("id") String customerId,
@Valid @RequestBody SetPreferencesRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Setting preferences for customer: {} by actor: {}", customerId, actorId.value());
var cmd = new SetPreferencesCommand(customerId, request.preferences());
var result = setPreferences.execute(cmd, actorId);
if (result.isFailure()) {
throw new CustomerDomainErrorException(result.unsafeGetError());
}
logger.info("Preferences set for customer: {}", customerId);
return ResponseEntity.ok(result.unsafeGetValue());
}
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 CustomerDomainErrorException extends RuntimeException {
private final CustomerError error;
public CustomerDomainErrorException(CustomerError error) {
super(error.message());
this.error = error;
}
public CustomerError getError() {
return error;
}
}
}

View file

@ -0,0 +1,14 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import jakarta.validation.constraints.NotBlank;
public record AddDeliveryAddressRequest(
String label,
@NotBlank String street,
String houseNumber,
@NotBlank String postalCode,
@NotBlank String city,
@NotBlank String country,
String contactPerson,
String deliveryNotes
) {}

View file

@ -0,0 +1,20 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.CustomerType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateCustomerRequest(
@NotBlank String name,
@NotNull CustomerType type,
@NotBlank String street,
String houseNumber,
@NotBlank String postalCode,
@NotBlank String city,
@NotBlank String country,
@NotBlank String phone,
String email,
String contactPerson,
Integer paymentDueDays,
String paymentDescription
) {}

View file

@ -0,0 +1,24 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.DeliveryRhythm;
import de.effigenix.domain.masterdata.Unit;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public record SetFrameContractRequest(
LocalDate validFrom,
LocalDate validUntil,
@NotNull DeliveryRhythm rhythm,
@NotEmpty List<LineItem> lineItems
) {
public record LineItem(
@NotNull String articleId,
@NotNull BigDecimal agreedPrice,
BigDecimal agreedQuantity,
Unit unit
) {}
}

View file

@ -0,0 +1,10 @@
package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.CustomerPreference;
import jakarta.validation.constraints.NotNull;
import java.util.Set;
public record SetPreferencesRequest(
@NotNull Set<CustomerPreference> preferences
) {}

View file

@ -0,0 +1,15 @@
package de.effigenix.infrastructure.masterdata.web.dto;
public record UpdateCustomerRequest(
String name,
String street,
String houseNumber,
String postalCode,
String city,
String country,
String phone,
String email,
String contactPerson,
Integer paymentDueDays,
String paymentDescription
) {}

View file

@ -1,6 +1,7 @@
package de.effigenix.infrastructure.masterdata.web.exception;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.CustomerError;
import de.effigenix.domain.masterdata.ProductCategoryError;
import de.effigenix.domain.masterdata.SupplierError;
@ -46,4 +47,17 @@ public final class MasterDataErrorHttpStatusMapper {
case ProductCategoryError.RepositoryFailure e -> 500;
};
}
public static int toHttpStatus(CustomerError error) {
return switch (error) {
case CustomerError.CustomerNotFound e -> 404;
case CustomerError.CustomerNameAlreadyExists e -> 409;
case CustomerError.FrameContractNotAllowed e -> 400;
case CustomerError.InvalidFrameContract e -> 400;
case CustomerError.DuplicateContractLineItem e -> 409;
case CustomerError.ValidationFailure e -> 400;
case CustomerError.Unauthorized e -> 403;
case CustomerError.RepositoryFailure e -> 500;
};
}
}

View file

@ -2,9 +2,11 @@ package de.effigenix.infrastructure.usermanagement.web.exception;
import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError;
import de.effigenix.domain.masterdata.CustomerError;
import de.effigenix.domain.masterdata.SupplierError;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
import de.effigenix.infrastructure.masterdata.web.controller.SupplierController;
import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpStatusMapper;
@ -156,6 +158,25 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(CustomerController.CustomerDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleCustomerDomainError(
CustomerController.CustomerDomainErrorException ex,
HttpServletRequest request
) {
CustomerError error = ex.getError();
int status = MasterDataErrorHttpStatusMapper.toHttpStatus(error);
logger.warn("Customer 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.
*

View file

@ -0,0 +1,155 @@
<?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="007-create-customers-table" author="effigenix">
<createTable tableName="customers">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false" unique="true"/>
</column>
<column name="type" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="phone" type="VARCHAR(50)"/>
<column name="email" type="VARCHAR(255)"/>
<column name="contact_person" type="VARCHAR(200)"/>
<column name="billing_street" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="billing_house_number" type="VARCHAR(20)"/>
<column name="billing_postal_code" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="billing_city" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="billing_country" type="VARCHAR(2)">
<constraints nullable="false"/>
</column>
<column name="payment_due_days" type="INT"/>
<column name="payment_description" type="VARCHAR(500)"/>
<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>
<sql>
ALTER TABLE customers ADD CONSTRAINT chk_customer_type CHECK (type IN ('B2C', 'B2B'));
ALTER TABLE customers ADD CONSTRAINT chk_customer_status CHECK (status IN ('ACTIVE', 'INACTIVE'));
</sql>
<createIndex tableName="customers" indexName="idx_customers_name">
<column name="name"/>
</createIndex>
<createIndex tableName="customers" indexName="idx_customers_type">
<column name="type"/>
</createIndex>
<createIndex tableName="customers" indexName="idx_customers_status">
<column name="status"/>
</createIndex>
</changeSet>
<changeSet id="007-create-delivery-addresses-table" author="effigenix">
<createTable tableName="delivery_addresses">
<column name="customer_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="label" type="VARCHAR(100)"/>
<column name="street" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="house_number" type="VARCHAR(20)"/>
<column name="postal_code" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="city" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="country" type="VARCHAR(2)">
<constraints nullable="false"/>
</column>
<column name="contact_person" type="VARCHAR(200)"/>
<column name="delivery_notes" type="VARCHAR(500)"/>
</createTable>
<addForeignKeyConstraint baseTableName="delivery_addresses" baseColumnNames="customer_id"
referencedTableName="customers" referencedColumnNames="id"
constraintName="fk_delivery_addresses_customer" onDelete="CASCADE"/>
<createIndex tableName="delivery_addresses" indexName="idx_delivery_addresses_customer_id">
<column name="customer_id"/>
</createIndex>
</changeSet>
<changeSet id="007-create-customer-preferences-table" author="effigenix">
<createTable tableName="customer_preferences">
<column name="customer_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="preference" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
</createTable>
<addPrimaryKey tableName="customer_preferences" columnNames="customer_id, preference"/>
<addForeignKeyConstraint baseTableName="customer_preferences" baseColumnNames="customer_id"
referencedTableName="customers" referencedColumnNames="id"
constraintName="fk_customer_preferences_customer" onDelete="CASCADE"/>
</changeSet>
<changeSet id="007-create-frame-contracts-table" author="effigenix">
<createTable tableName="frame_contracts">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="customer_id" type="VARCHAR(36)">
<constraints nullable="false" unique="true"/>
</column>
<column name="valid_from" type="DATE"/>
<column name="valid_until" type="DATE"/>
<column name="delivery_rhythm" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="frame_contracts" baseColumnNames="customer_id"
referencedTableName="customers" referencedColumnNames="id"
constraintName="fk_frame_contracts_customer" onDelete="CASCADE"/>
<createIndex tableName="frame_contracts" indexName="idx_frame_contracts_customer_id">
<column name="customer_id"/>
</createIndex>
</changeSet>
<changeSet id="007-create-contract-line-items-table" author="effigenix">
<createTable tableName="contract_line_items">
<column name="frame_contract_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="article_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="agreed_price_amount" type="DECIMAL(19,2)">
<constraints nullable="false"/>
</column>
<column name="agreed_price_currency" type="VARCHAR(3)">
<constraints nullable="false"/>
</column>
<column name="agreed_quantity" type="DECIMAL(19,4)"/>
<column name="unit" type="VARCHAR(30)"/>
</createTable>
<addPrimaryKey tableName="contract_line_items" columnNames="frame_contract_id, article_id"/>
<addForeignKeyConstraint baseTableName="contract_line_items" baseColumnNames="frame_contract_id"
referencedTableName="frame_contracts" referencedColumnNames="id"
constraintName="fk_contract_line_items_contract" onDelete="CASCADE"/>
<createIndex tableName="contract_line_items" indexName="idx_contract_line_items_contract_id">
<column name="frame_contract_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -11,5 +11,6 @@
<include file="db/changelog/changes/004-seed-admin-user.xml"/>
<include file="db/changelog/changes/005-create-masterdata-schema.xml"/>
<include file="db/changelog/changes/006-create-supplier-schema.xml"/>
<include file="db/changelog/changes/007-create-customer-schema.xml"/>
</databaseChangeLog>