AnalyticsController.java

package com.smartsupplypro.inventory.controller;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.smartsupplypro.inventory.dto.DashboardSummaryDTO;
import com.smartsupplypro.inventory.dto.FinancialSummaryDTO;
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.service.AnalyticsService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

/**
 * Analytics REST controller for inventory reporting and dashboard data.
 *
 * <p>Provides time-series analytics, KPIs, and filtered reports.
 * Supports demo mode for read-only endpoints, authenticated access for mutations.</p>
 *
 * @see AnalyticsService
 * @see <a href="file:../../../../../../docs/architecture/patterns/controller-patterns.md">Controller Patterns</a>
 */
@RestController
@RequestMapping(value = "/api/analytics", produces = MediaType.APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
@Validated
public class AnalyticsController{

    // Enterprise Comment: Demo Mode Security Pattern
    // Read-only endpoints use: @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    // This allows public access in demo mode while maintaining authentication for production.
    // Mutating operations (POST/PUT/PATCH/DELETE) always require authentication.
    private final AnalyticsService analyticsService;

    /**
     * Gets time series of total stock value between dates.
     *
     * @param start      inclusive start date (ISO yyyy-MM-dd)
     * @param end        inclusive end date (ISO yyyy-MM-dd)
     * @param supplierId optional supplier filter
     * @return list of stock value points over time
     * @throws InvalidRequestException if date range is invalid
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/stock-value")
    public ResponseEntity<List<StockValueOverTimeDTO>> getStockValueOverTime(
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end,
            @RequestParam(required = false) String supplierId) {

        validateDateRange(start, end, "start", "end");
        return ResponseEntity.ok(analyticsService.getTotalStockValueOverTime(start, end, supplierId));
    }

    /**
     * Gets current total stock per supplier for charts.
     *
     * @return list of stock quantities per supplier
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/stock-per-supplier")
    public ResponseEntity<List<StockPerSupplierDTO>> getStockPerSupplier() {
        return ResponseEntity.ok(analyticsService.getTotalStockPerSupplier());
    }

    /**
     * Gets count of items below minimum stock threshold.
     *
     * @return number of low-stock items
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/low-stock/count")
    public long getLowStockCount() {
        return analyticsService.lowStockCount();
    }

    /**
     * Gets item update frequency for a supplier.
     *
     * @param supplierId required supplier identifier
     * @return list of item update frequencies
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/item-update-frequency")
    public ResponseEntity<List<ItemUpdateFrequencyDTO>> getItemUpdateFrequency(
            @RequestParam(name = "supplierId") String supplierId) {

        requireNonBlank(supplierId, "supplierId");
        return ResponseEntity.ok(analyticsService.getItemUpdateFrequency(supplierId));
    }

    /**
     * Gets items below minimum stock threshold for a supplier.
     *
     * @param supplierId required supplier identifier
     * @return list of low-stock items with details
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/low-stock-items")
    public ResponseEntity<List<LowStockItemDTO>> getLowStockItems(
            @RequestParam(name = "supplierId") String supplierId) {

        requireNonBlank(supplierId, "supplierId");
        return ResponseEntity.ok(analyticsService.getItemsBelowMinimumStock(supplierId));
    }

    /**
     * Gets monthly stock movement within date range.
     *
     * @param start      inclusive start date (ISO yyyy-MM-dd)
     * @param end        inclusive end date (ISO yyyy-MM-dd)
     * @param supplierId optional supplier filter
     * @return list of monthly stock movement data
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/monthly-stock-movement")
    public ResponseEntity<List<MonthlyStockMovementDTO>> getMonthlyStockMovement(
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end,
            @RequestParam(required = false) String supplierId) {

        validateDateRange(start, end, "start", "end");
        return ResponseEntity.ok(analyticsService.getMonthlyStockMovement(start, end, supplierId));
    }

    /**
     * Gets filtered stock updates via query parameters.
     *
     * <p>Defaults to last 30 days if no dates provided.</p>
     *
     * @param startDate  optional start timestamp
     * @param endDate    optional end timestamp
     * @param itemName   optional item name filter
     * @param supplierId optional supplier filter
     * @param createdBy  optional creator filter
     * @param minChange  optional minimum quantity change
     * @param maxChange  optional maximum quantity change
     * @return list of filtered stock updates
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/stock-updates")
    public ResponseEntity<List<StockUpdateResultDTO>> getFilteredStockUpdatesFromParams(
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
            @RequestParam(required = false) String itemName,
            @RequestParam(required = false) String supplierId,
            @RequestParam(required = false) String createdBy,
            @RequestParam(required = false) Integer minChange,
            @RequestParam(required = false) Integer maxChange) {

        // Enterprise Comment: Default Date Window Strategy
        // When no dates provided, default to last 30 days to prevent unbounded queries
        // that could impact performance on large datasets
        if (startDate == null && endDate == null) {
            endDate = LocalDateTime.now();
            startDate = endDate.minusDays(30);
        }
        // Validate date & numeric params
        if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
            throw new InvalidRequestException("startDate must be on or before endDate");
        }
        if (minChange != null && maxChange != null && minChange > maxChange) {
            throw new InvalidRequestException("minChange must be <= maxChange");
        }

        StockUpdateFilterDTO filter = new StockUpdateFilterDTO();
        filter.setStartDate(startDate);
        filter.setEndDate(endDate);
        filter.setItemName(itemName);
        filter.setSupplierId(supplierId);
        filter.setCreatedBy(createdBy);
        filter.setMinChange(minChange);
        filter.setMaxChange(maxChange);

        return ResponseEntity.ok(analyticsService.getFilteredStockUpdates(filter));
    }

    /**
     * Gets filtered stock updates via JSON payload.
     *
     * @param filter stock update filter criteria
     * @return list of filtered stock updates
     */
    @PreAuthorize("isAuthenticated()")
    @PostMapping(value = "/stock-updates/query", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<List<StockUpdateResultDTO>> getFilteredStockUpdatesPost(
            @RequestBody @Valid StockUpdateFilterDTO filter) {

        if (filter.getStartDate() != null && filter.getEndDate() != null
                && filter.getStartDate().isAfter(filter.getEndDate())) {
            throw new InvalidRequestException("startDate must be on or before endDate");
        }
        if (filter.getMinChange() != null && filter.getMaxChange() != null
                && filter.getMinChange() > filter.getMaxChange()) {
            throw new InvalidRequestException("minChange must be <= maxChange");
        }

        return ResponseEntity.ok(analyticsService.getFilteredStockUpdates(filter));
    }

    /**
     * Gets dashboard summary with multiple analytics.
     *
     * <p>Defaults to last 30 days if no dates provided.</p>
     *
     * @param supplierId optional supplier filter
     * @param startDate  optional start timestamp
     * @param endDate    optional end timestamp
     * @return dashboard summary with multiple data sets
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/summary")
    public ResponseEntity<DashboardSummaryDTO> getDashboardSummary(
            @RequestParam(required = false) String supplierId,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) {

        if (startDate == null) startDate = LocalDateTime.now().minusDays(30);
        if (endDate == null) endDate = LocalDateTime.now();
        if (startDate.isAfter(endDate)) {
            throw new InvalidRequestException("startDate must be on or before endDate");
        }

        DashboardSummaryDTO summary = new DashboardSummaryDTO();
        summary.setStockPerSupplier(analyticsService.getTotalStockPerSupplier());

        summary.setLowStockItems(supplierId != null && !supplierId.isBlank()
                ? analyticsService.getItemsBelowMinimumStock(supplierId).stream().limit(3).toList()
                : List.of());

        summary.setMonthlyStockMovement(analyticsService.getMonthlyStockMovement(
                startDate.toLocalDate(), endDate.toLocalDate(), supplierId));

        summary.setTopUpdatedItems(supplierId != null && !supplierId.isBlank()
                ? analyticsService.getItemUpdateFrequency(supplierId).stream().limit(5).toList()
                : List.of());

        return ResponseEntity.ok(summary);
    }

    /**
     * Gets historical price changes for an item.
     *
     * @param itemId     required inventory item ID
     * @param supplierId optional supplier filter
     * @param start      inclusive start date (ISO yyyy-MM-dd)
     * @param end        inclusive end date (ISO yyyy-MM-dd)
     * @return list of price trend data points
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/price-trend")
    public ResponseEntity<List<PriceTrendDTO>> getPriceTrend(
            @RequestParam String itemId,
            @RequestParam(required = false) String supplierId,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end) {

        requireNonBlank(itemId, "itemId");
        validateDateRange(start, end, "start", "end");
        return ResponseEntity.ok(analyticsService.getPriceTrend(itemId, supplierId, start, end));
    }

    /**
     * Gets financial summary with WAC calculations.
     *
     * @param from       inclusive start date (ISO yyyy-MM-dd)
     * @param to         inclusive end date (ISO yyyy-MM-dd)
     * @param supplierId optional supplier filter
     * @return financial summary with purchases, COGS, write-offs
     */
    @PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
    @GetMapping("/financial/summary")
    public ResponseEntity<FinancialSummaryDTO> getFinancialSummary(
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
            @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
            @RequestParam(required = false) String supplierId) {

        // reuse helper for date validation
        validateDateRange(from, to, "from", "to");
        return ResponseEntity.ok(analyticsService.getFinancialSummaryWAC(from, to, supplierId));
    }

    // ------------------------------------------------------------------------
    // Validation Helpers
    // ------------------------------------------------------------------------

    /**
     * Validates date range parameters.
     *
     * @param start     start date (must not be null)
     * @param end       end date (must not be null and >= start)
     * @param startName parameter name for error messages
     * @param endName   parameter name for error messages
     * @throws InvalidRequestException if validation fails
     */
    private static void validateDateRange(LocalDate start, LocalDate end,
                                          String startName, String endName) {
        if (start == null || end == null) {
            throw new InvalidRequestException(startName + " and " + endName + " are required");
        }
        if (start.isAfter(end)) {
            throw new InvalidRequestException(startName + " must be on or before " + endName);
        }
    }

    /**
     * Validates string parameter is not blank.
     *
     * @param value parameter value to check
     * @param name  parameter name for error messages
     * @throws InvalidRequestException if value is blank
     */
    private static void requireNonBlank(String value, String name) {
        if (value == null || value.trim().isEmpty()) {
            throw new InvalidRequestException(name + " must not be blank");
        }
    }
}