ProductService.java
package com.stocks.stockease.product;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.stocks.stockease.product.internal.ProductRepository;
import com.stocks.stockease.security.User;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
/**
* Product module's public API for querying and mutating products.
* Other modules depend on this service rather than reaching into the module's repository.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
/**
* Returns all products ordered by ID ascending.
*
* @return list of all products
*/
public List<Product> getAllProducts() {
return productRepository.findAllOrderById();
}
/**
* Returns a paginated slice of the product catalogue.
*
* @param pageable page and size parameters
* @return the requested page of products
*/
public Page<Product> getPagedProducts(Pageable pageable) {
return productRepository.findAll(pageable);
}
/**
* Finds a product by its ID.
*
* @param id product identifier
* @return the product, or empty if none exists with that ID
*/
public Optional<Product> findById(long id) {
return productRepository.findById(id);
}
/**
* Creates and persists a new product.
*
* @param name product name; must not duplicate a live product's name, ignoring case
* @param quantity stock quantity
* @param purchasePrice unit purchase price
* @return the persisted product including its generated ID
* @throws IllegalStateException if a live product already carries that name
*/
@Transactional
public Product create(String name, int quantity, double purchasePrice) {
// service check gives the friendly message, the partial unique index in the database is the
// concurrency backstop
if (productRepository.existsByNameIgnoreCase(name)) {
throw new IllegalStateException("A product named '" + name + "' already exists.");
}
return productRepository.save(new Product(name, quantity, purchasePrice));
}
/**
* Soft-deletes a product by ID and records the deletion in the change log.
*
* @param id product identifier
* @param user user performing the deletion
* @return {@code true} if the product existed and was deleted, {@code false} if no such product exists
*/
@Transactional
public boolean deleteById(long id, User user) {
Optional<Product> found = productRepository.findById(id);
if (found.isEmpty()) {
return false;
}
// stamped explicitly rather than via repository.deleteById: that marks the entity removed, and the
// change log row written by the listener may not reference a removed instance. Same soft-delete
// result, and symmetric with restore below.
Product product = found.get();
product.setDeletedAt(LocalDateTime.now());
productRepository.save(product);
eventPublisher.publishEvent(
new ProductChangedEvent(product, user, ProductChangedEvent.Field.DELETED, null, null));
return true;
}
/**
* Revives a soft-deleted product, provided no live product has since taken its name or SKU.
*
* @param id product identifier
* @param user user performing the restore
* @return the restored product
* @throws EntityNotFoundException if no soft-deleted product exists with the given ID
* @throws IllegalStateException if a live product already carries the same name or SKU
*/
@Transactional
public Product restore(long id, User user) {
Product product = productRepository.findDeletedById(id)
.orElseThrow(() -> new EntityNotFoundException("No soft-deleted product with ID " + id + " found."));
// live rows only - @SQLRestriction scopes both exists queries; the partial unique indexes are the
// concurrency backstop
if (productRepository.existsByNameIgnoreCase(product.getName())) {
throw new IllegalStateException(
"Cannot restore: a live product named '" + product.getName() + "' already exists.");
}
if (productRepository.existsBySku(product.getSku())) {
throw new IllegalStateException(
"Cannot restore: a live product with SKU '" + product.getSku() + "' already exists.");
}
product.setDeletedAt(null);
Product saved = productRepository.save(product);
eventPublisher.publishEvent(
new ProductChangedEvent(saved, user, ProductChangedEvent.Field.RESTORED, null, null));
return saved;
}
/**
* Returns products whose stock quantity falls below {@code threshold}.
*
* @param threshold quantity boundary (exclusive)
* @return list of products below the threshold
*/
public List<Product> findLowStock(int threshold) {
return productRepository.findByQuantityLessThan(threshold);
}
/**
* Searches products by a case-insensitive substring match on name.
*
* @param name search substring
* @return list of matching products
*/
public List<Product> searchByName(String name) {
return productRepository.findByNameContainingIgnoreCase(name);
}
/**
* Updates a product's stock quantity.
*
* @param id product identifier
* @param quantity new quantity
* @return the updated product
* @throws EntityNotFoundException if no product exists with the given ID
*/
@Transactional
public Product updateQuantity(long id, int quantity) {
// quantity history lives exclusively in stock movements; never logged to the change log
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
product.setQuantity(quantity);
return productRepository.save(product);
}
/**
* Applies a relative change to a product's stock quantity.
*
* @param id product identifier
* @param delta signed number of units to add to the current quantity
* @return the updated product
* @throws EntityNotFoundException if no product exists with the given ID
* @throws IllegalStateException if the adjustment would drive the quantity below zero
*/
@Transactional
public Product adjustQuantity(long id, int delta) {
// pessimistic lock serializes concurrent adjustments so the negative-stock check cannot race
Product product = productRepository.findByIdForUpdate(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
int newQuantity = product.getQuantity() + delta;
if (newQuantity < 0) {
throw new IllegalStateException("Adjustment of " + delta + " would result in negative stock for product "
+ id + " (current: " + product.getQuantity() + ").");
}
product.setQuantity(newQuantity);
return productRepository.save(product);
}
/**
* Updates a product's purchase price and records the change in the change log.
*
* @param id product identifier
* @param purchasePrice new purchase price
* @param user user making the change
* @return the updated product
* @throws EntityNotFoundException if no product exists with the given ID
*/
@Transactional
public Product updatePrice(long id, BigDecimal purchasePrice, User user) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
BigDecimal oldPrice = product.getPurchasePrice();
product.setPurchasePrice(purchasePrice);
Product saved = productRepository.save(product);
// compareTo, not equals: 2.50 and 2.5 are the same price at different scales and are not a change
if (oldPrice.compareTo(purchasePrice) != 0) {
eventPublisher.publishEvent(new ProductChangedEvent(saved, user,
ProductChangedEvent.Field.PURCHASE_PRICE, oldPrice.toPlainString(),
purchasePrice.toPlainString()));
}
return saved;
}
/**
* Updates a product's name and records the change in the change log.
*
* @param id product identifier
* @param name new name; must not duplicate another live product's name, ignoring case
* @param user user making the change
* @return the updated product
* @throws EntityNotFoundException if no product exists with the given ID
* @throws IllegalStateException if a different live product already carries that name
*/
@Transactional
public Product updateName(long id, String name, User user) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
// excluding this product's own row is what lets a rename fix only the capitalization of its own name
if (productRepository.existsByNameIgnoreCaseAndIdNot(name, id)) {
throw new IllegalStateException("A product named '" + name + "' already exists.");
}
String oldName = product.getName();
product.setName(name);
Product saved = productRepository.save(product);
// exact comparison: a pure capitalization fix IS a change and is logged
if (!name.equals(oldName)) {
eventPublisher.publishEvent(
new ProductChangedEvent(saved, user, ProductChangedEvent.Field.NAME, oldName, name));
}
return saved;
}
/**
* Calculates the aggregate inventory value across all products.
*
* @return sum of quantity times purchase price across all products
*/
public double getTotalStockValue() {
return productRepository.calculateTotalStockValue();
}
}