StockAnalyticsService.java
package com.smartsupplypro.inventory.service.impl.analytics;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.smartsupplypro.inventory.dto.ItemUpdateFrequencyDTO;
import com.smartsupplypro.inventory.dto.LowStockItemDTO;
import com.smartsupplypro.inventory.dto.MonthlyStockMovementDTO;
import com.smartsupplypro.inventory.dto.PriceTrendDTO;
import com.smartsupplypro.inventory.dto.StockPerSupplierDTO;
import com.smartsupplypro.inventory.dto.StockUpdateFilterDTO;
import com.smartsupplypro.inventory.dto.StockUpdateResultDTO;
import com.smartsupplypro.inventory.dto.StockValueOverTimeDTO;
import com.smartsupplypro.inventory.exception.InvalidRequestException;
import com.smartsupplypro.inventory.repository.InventoryItemRepository;
import com.smartsupplypro.inventory.repository.StockHistoryRepository;
import lombok.RequiredArgsConstructor;
import static com.smartsupplypro.inventory.service.impl.analytics.AnalyticsConverterHelper.*;
/**
* Stock analytics service for inventory metrics and reporting.
*
* <p>Provides read-only analytics operations including:
* <ul>
* <li>Stock valuation trends (daily inventory value)</li>
* <li>Supplier performance metrics (stock distribution, activity)</li>
* <li>Low stock alerts (threshold-based warnings)</li>
* <li>Movement trends (monthly stock-in/stock-out)</li>
* <li>Price history (item price trends over time)</li>
* <li>Advanced filtering (multi-criteria stock update queries)</li>
* </ul>
*
* <p><strong>Design Notes</strong>:
* <ul>
* <li>All operations are read-only ({@code @Transactional(readOnly = true)})</li>
* <li>Date windows default to last 30 days when not specified</li>
* <li>Handles H2 (test) and Oracle (prod) type differences via converter helpers</li>
* </ul>
*
* @author Smart Supply Pro Development Team
* @version 1.0.0
* @since 2.0.0
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StockAnalyticsService {
private final StockHistoryRepository stockHistoryRepository;
private final InventoryItemRepository inventoryItemRepository;
/**
* Retrieves daily inventory value (quantity × price) over a date range.
*
* <p>Defaults to last 30 days if bounds are {@code null}.
*
* @param startDate inclusive start date (nullable)
* @param endDate inclusive end date (nullable)
* @param supplierId optional supplier filter ({@code null/blank} = all suppliers)
* @return ordered list of daily stock values (ascending by date)
* @throws InvalidRequestException if {@code startDate > endDate}
*/
public List<StockValueOverTimeDTO> getTotalStockValueOverTime(LocalDate startDate,
LocalDate endDate,
String supplierId) {
LocalDate[] window = defaultAndValidateDateWindow(startDate, endDate);
LocalDateTime from = startOfDay(window[0]);
LocalDateTime to = endOfDay(window[1]);
List<Object[]> rows = stockHistoryRepository.getDailyStockValuation(from, to, blankToNull(supplierId));
return rows.stream()
.map(r -> new StockValueOverTimeDTO(
asLocalDate(r[0]),
asNumber(r[1]).doubleValue()
))
.toList();
}
/**
* Retrieves current stock quantities grouped by supplier.
*
* @return list of suppliers with total quantities (ordered by quantity desc)
*/
public List<StockPerSupplierDTO> getTotalStockPerSupplier() {
List<Object[]> rows = stockHistoryRepository.getTotalStockBySupplier();
return rows.stream()
.map(r -> new StockPerSupplierDTO(
(String) r[0],
asNumber(r[1]).longValue()
))
.toList();
}
/**
* Retrieves stock update frequency per item for a supplier.
*
* <p>Counts stock history entries per item (higher count = more active product).
*
* @param supplierId supplier identifier (required)
* @return list of items with update counts (ordered by count desc)
* @throws InvalidRequestException if {@code supplierId} is blank
*/
public List<ItemUpdateFrequencyDTO> getItemUpdateFrequency(String supplierId) {
String sid = requireNonBlank(supplierId, "supplierId");
List<Object[]> rows = stockHistoryRepository.getUpdateCountByItem(sid);
return rows.stream()
.map(r -> new ItemUpdateFrequencyDTO(
(String) r[0],
asNumber(r[1]).longValue()
))
.toList();
}
/**
* Identifies items below minimum stock threshold for a supplier.
*
* <p><strong>Business Rule</strong>: Low stock when {@code currentQuantity < minimumQuantity}.
*
* @param supplierId supplier identifier (required)
* @return list of low-stock items (ordered by quantity asc, most critical first)
* @throws InvalidRequestException if {@code supplierId} is blank
*/
public List<LowStockItemDTO> getItemsBelowMinimumStock(String supplierId) {
String sid = requireNonBlank(supplierId, "supplierId");
List<Object[]> rows = inventoryItemRepository.findItemsBelowMinimumStockFiltered(sid);
return rows.stream()
.map(r -> new LowStockItemDTO(
(String) r[0],
asNumber(r[1]).intValue(),
asNumber(r[2]).intValue()
))
.toList();
}
/**
* Aggregates stock movements into monthly buckets (stock-in vs stock-out).
*
* <p>Defaults to last 30 days if bounds are {@code null}.
*
* @param startDate inclusive start date (nullable)
* @param endDate inclusive end date (nullable)
* @param supplierId optional supplier filter ({@code null/blank} = all suppliers)
* @return list of monthly movements (YYYY-MM format, ordered by month asc)
* @throws InvalidRequestException if {@code startDate > endDate}
*/
public List<MonthlyStockMovementDTO> getMonthlyStockMovement(LocalDate startDate,
LocalDate endDate,
String supplierId) {
LocalDate[] window = defaultAndValidateDateWindow(startDate, endDate);
LocalDateTime from = startOfDay(window[0]);
LocalDateTime to = endOfDay(window[1]);
List<Object[]> rows = stockHistoryRepository.getMonthlyStockMovementBySupplier(from, to, blankToNull(supplierId));
return rows.stream()
.map(r -> new MonthlyStockMovementDTO(
(String) r[0],
asNumber(r[1]).longValue(),
asNumber(r[2]).longValue()
))
.toList();
}
/**
* Total number of items currently below minimum stock threshold.
*
* @return count of low-stock items (global KPI, no supplier filter)
*/
public long lowStockCount() {
return inventoryItemRepository.countWithQuantityBelow(5);
}
/**
* Applies flexible filter over stock updates (multi-criteria query).
*
* <p>Defaults to last 30 days if date bounds are {@code null}.
*
* @param filter filter object with optional criteria (required, must not be {@code null})
* @return list of stock updates (ordered by createdAt DESC)
* @throws InvalidRequestException if filter is {@code null} or validation fails
*/
public List<StockUpdateResultDTO> getFilteredStockUpdates(StockUpdateFilterDTO filter) {
if (filter == null) {
throw new InvalidRequestException("filter must not be null");
}
LocalDateTime start = filter.getStartDate();
LocalDateTime end = filter.getEndDate();
// Apply 30-day default window
if (start == null && end == null) {
end = LocalDateTime.now();
start = end.minusDays(30);
}
if (start != null && end != null && start.isAfter(end)) {
throw new InvalidRequestException("startDate must be on or before endDate");
}
// Validate quantity range
Integer min = filter.getMinChange();
Integer max = filter.getMaxChange();
if (min != null && max != null && min > max) {
throw new InvalidRequestException("minChange must be <= maxChange");
}
String itemName = blankToNull(filter.getItemName());
String supplierId = blankToNull(filter.getSupplierId());
String createdBy = blankToNull(filter.getCreatedBy());
List<Object[]> rows = stockHistoryRepository.searchStockUpdates(
start, end, itemName, supplierId, createdBy, min, max
);
return rows.stream()
.map(r -> new StockUpdateResultDTO(
(String) r[0],
(String) r[1],
asNumber(r[2]).intValue(),
(String) r[3],
(String) r[4],
asLocalDateTime(r[5])
))
.toList();
}
/**
* Returns average unit price per day for an item within a date window.
*
* @param itemId required inventory item identifier
* @param supplierId optional supplier filter ({@code null/blank} = all suppliers)
* @param start inclusive start date (required)
* @param end inclusive end date (required)
* @return ordered list of day/price pairs (ascending by date)
* @throws InvalidRequestException if {@code itemId} is blank or {@code start > end}
*/
public List<PriceTrendDTO> getPriceTrend(String itemId, String supplierId, LocalDate start, LocalDate end) {
String iid = requireNonBlank(itemId, "itemId");
LocalDate s = requireNonNull(start, "start");
LocalDate e = requireNonNull(end, "end");
if (s.isAfter(e)) {
throw new InvalidRequestException("start must be on or before end");
}
LocalDateTime from = startOfDay(s);
LocalDateTime to = endOfDay(e);
return stockHistoryRepository.getItemPriceTrend(iid, supplierId, from, to);
}
}