diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/MasterDataUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/MasterDataUseCaseConfiguration.java index d77b0df..96e7a0a 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/MasterDataUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/MasterDataUseCaseConfiguration.java @@ -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); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/ContractLineItemEmbeddable.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/ContractLineItemEmbeddable.java new file mode 100644 index 0000000..e495f62 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/ContractLineItemEmbeddable.java @@ -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; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/CustomerEntity.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/CustomerEntity.java new file mode 100644 index 0000000..b22a4bf --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/CustomerEntity.java @@ -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 deliveryAddresses = new ArrayList<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "customer_preferences", joinColumns = @JoinColumn(name = "customer_id")) + @Column(name = "preference") + @Enumerated(EnumType.STRING) + private Set 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 getDeliveryAddresses() { return deliveryAddresses; } + public Set 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 deliveryAddresses) { this.deliveryAddresses = deliveryAddresses; } + public void setPreferences(Set preferences) { this.preferences = preferences; } + public void setFrameContract(FrameContractEntity frameContract) { this.frameContract = frameContract; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/DeliveryAddressEmbeddable.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/DeliveryAddressEmbeddable.java new file mode 100644 index 0000000..12926c8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/DeliveryAddressEmbeddable.java @@ -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; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/FrameContractEntity.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/FrameContractEntity.java new file mode 100644 index 0000000..92a37cc --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/entity/FrameContractEntity.java @@ -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 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 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 lineItems) { this.lineItems = lineItems; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/mapper/CustomerMapper.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/mapper/CustomerMapper.java new file mode 100644 index 0000000..8b72693 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/mapper/CustomerMapper.java @@ -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 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 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; + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/CustomerJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/CustomerJpaRepository.java new file mode 100644 index 0000000..0827317 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/CustomerJpaRepository.java @@ -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 { + + List findByType(String type); + + List findByStatus(String status); + + boolean existsByName(String name); +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java new file mode 100644 index 0000000..16d906a --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/FrameContractJpaRepository.java @@ -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 { + + Optional findByCustomerId(String customerId); + + void deleteByCustomerId(String customerId); +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java new file mode 100644 index 0000000..00d822b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/persistence/repository/JpaCustomerRepository.java @@ -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> 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> findAll() { + try { + List 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> findByType(CustomerType type) { + try { + List 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> findByStatus(CustomerStatus status) { + try { + List 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 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 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 existsByName(CustomerName name) { + try { + return Result.success(jpaRepository.existsByName(name.value())); + } catch (Exception e) { + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/controller/CustomerController.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/controller/CustomerController.java new file mode 100644 index 0000000..0149033 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/controller/CustomerController.java @@ -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 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> 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> 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 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 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 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 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 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 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 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 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 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; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/AddDeliveryAddressRequest.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/AddDeliveryAddressRequest.java new file mode 100644 index 0000000..1fa5330 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/AddDeliveryAddressRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/CreateCustomerRequest.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/CreateCustomerRequest.java new file mode 100644 index 0000000..6fa9dc1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/CreateCustomerRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/SetFrameContractRequest.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/SetFrameContractRequest.java new file mode 100644 index 0000000..a6770e0 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/SetFrameContractRequest.java @@ -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 lineItems +) { + public record LineItem( + @NotNull String articleId, + @NotNull BigDecimal agreedPrice, + BigDecimal agreedQuantity, + Unit unit + ) {} +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/SetPreferencesRequest.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/SetPreferencesRequest.java new file mode 100644 index 0000000..8d16cd6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/SetPreferencesRequest.java @@ -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 preferences +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/UpdateCustomerRequest.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/UpdateCustomerRequest.java new file mode 100644 index 0000000..8ef3ac5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/dto/UpdateCustomerRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/exception/MasterDataErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/exception/MasterDataErrorHttpStatusMapper.java index 1cae856..862d6b0 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/exception/MasterDataErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/masterdata/web/exception/MasterDataErrorHttpStatusMapper.java @@ -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; + }; + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java index 2eae45c..8ecc169 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java @@ -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 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. * diff --git a/backend/src/main/resources/db/changelog/changes/007-create-customer-schema.xml b/backend/src/main/resources/db/changelog/changes/007-create-customer-schema.xml new file mode 100644 index 0000000..49d1b43 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/007-create-customer-schema.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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')); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 1b369bc..a1d6cec 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -11,5 +11,6 @@ +