FinancialAnalyticsService.java

package com.smartsupplypro.inventory.service.impl.analytics;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.smartsupplypro.inventory.dto.FinancialSummaryDTO;
import com.smartsupplypro.inventory.enums.StockChangeReason;
import com.smartsupplypro.inventory.exception.InvalidRequestException;
import com.smartsupplypro.inventory.repository.StockHistoryRepository;
import static com.smartsupplypro.inventory.service.impl.analytics.AnalyticsConverterHelper.blankToNull;

import lombok.RequiredArgsConstructor;

/**
 * Financial analytics service implementing Weighted Average Cost (WAC) calculations.
 *
 * <p>Provides comprehensive financial summaries by replaying stock events to calculate:
 * <ul>
 *   <li>Opening inventory (quantity and value at period start)</li>
 *   <li>Purchases (new stock acquisitions with costs)</li>
 *   <li>Returns (customer returns, returns to supplier)</li>
 *   <li>Cost of Goods Sold - COGS (items sold at WAC)</li>
 *   <li>Write-offs (damaged, lost, expired items)</li>
 *   <li>Ending inventory (quantity and value at period end)</li>
 * </ul>
 *
 * <p><strong>WAC Algorithm</strong>: Maintains running weighted average cost per item by
 * blending old and new costs proportionally when stock arrives at different prices.
 *
 * @author Smart Supply Pro Development Team
 * @version 1.0.0
 * @since 2.0.0
 * @see <a href="../../../../../../../docs/architecture/services/analytics-service.md#wac-algorithm">WAC Algorithm</a>
 */
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FinancialAnalyticsService {

    private final StockHistoryRepository stockHistoryRepository;

    // === Reason Categories for Financial Buckets ===
    private static final Set<StockChangeReason> RETURNS_IN = Set.of(StockChangeReason.RETURNED_BY_CUSTOMER);
    private static final Set<StockChangeReason> WRITE_OFFS = Set.of(
            StockChangeReason.DAMAGED, StockChangeReason.DESTROYED,
            StockChangeReason.SCRAPPED, StockChangeReason.EXPIRED, StockChangeReason.LOST
    );
    private static final Set<StockChangeReason> RETURN_TO_SUPPLIER = Set.of(StockChangeReason.RETURNED_TO_SUPPLIER);

    /**
     * Produces financial summary using Weighted Average Cost (WAC) method.
     *
     * <p><strong>Computation Model</strong>:
     * <ol>
     *   <li>Opening inventory: Replay events before period to establish baseline WAC per item</li>
     *   <li>Period events: Categorize into purchases, returns, COGS, write-offs, returns to supplier</li>
     *   <li>Ending inventory: Final state after processing all events</li>
     * </ol>
     *
     * <p><strong>WAC Formula</strong>: {@code newWAC = (oldQty × oldWAC + inboundQty × unitCost) / newQty}
     *
     * <p><strong>Financial Equation</strong>:
     * <pre>
     * Opening Value + Purchases Cost + Returns In Cost - COGS Cost - Write-off Cost = Ending Value
     * </pre>
     *
     * @param from inclusive start date (required)
     * @param to inclusive end date (required)
     * @param supplierId optional supplier filter ({@code null/blank} = all suppliers)
     * @return WAC-based financial summary with all categories
     * @throws InvalidRequestException if {@code from/to} is {@code null} or {@code from > to}
     */
    public FinancialSummaryDTO getFinancialSummaryWAC(LocalDate from, LocalDate to, String supplierId) {
        // === Input Validation ===
        if (from == null || to == null) throw new InvalidRequestException("from/to must be provided");
        if (from.isAfter(to)) throw new InvalidRequestException("from must be on or before to");

        // Convert LocalDate to LocalDateTime boundaries (inclusive range)
        LocalDateTime start = LocalDateTime.of(from, LocalTime.MIN);  // 00:00:00.000
        LocalDateTime end   = LocalDateTime.of(to,   LocalTime.MAX);  // 23:59:59.999

        // === Fetch Event Stream ===
        var events = stockHistoryRepository.streamEventsForWAC(end, blankToNull(supplierId));

        // === Initialize Financial Buckets ===
        long openingQty = 0, purchasesQty = 0, returnsInQty = 0, cogsQty = 0, writeOffQty = 0, endingQty = 0;
        BigDecimal openingValue = BigDecimal.ZERO,
                   purchasesCost = BigDecimal.ZERO,
                   returnsInCost = BigDecimal.ZERO,
                   cogsCost = BigDecimal.ZERO,
                   writeOffCost = BigDecimal.ZERO,
                   endingValue = BigDecimal.ZERO;

        // === Per-Item State Tracking (itemId → State) ===
        Map<String, WacState> state = new HashMap<>();

        // === PHASE 1: Opening Inventory (events before period start) ===
        for (var e : events) {
            if (e.createdAt().isBefore(start)) {
                WacState st = state.get(e.itemId());
                
                if (e.quantityChange() > 0) {
                    // Inbound: determine unit cost and update WAC
                    BigDecimal unit = (e.priceAtChange() != null)
                            ? e.priceAtChange()
                            : (st == null ? BigDecimal.ZERO : st.avgCost());
                    st = applyInbound(st, e.quantityChange(), unit);
                    state.put(e.itemId(), st);
                    
                } else if (e.quantityChange() < 0) {
                    // Outbound: issue at current WAC
                    WacIssue iss = issueAt(st, Math.abs(e.quantityChange()));
                    state.put(e.itemId(), iss.state());
                }
            }
        }
        
        // Sum opening inventory across all items
        for (var st : state.values()) {
            openingQty += st.qty();
            openingValue = openingValue.add(st.avgCost().multiply(BigDecimal.valueOf(st.qty())));
        }

        // === PHASE 2: Process Events Within Period [start..end] ===
        for (var e : events) {
            if (e.createdAt().isBefore(start)) continue;

            WacState st = state.get(e.itemId());

            if (e.quantityChange() > 0) {
                // === INBOUND: Purchases, Returns ===
                BigDecimal unit = (e.priceAtChange() != null)
                        ? e.priceAtChange()
                        : (st == null ? BigDecimal.ZERO : st.avgCost());
                
                WacState newSt = applyInbound(st, e.quantityChange(), unit);
                state.put(e.itemId(), newSt);

                if (RETURNS_IN.contains(e.reason())) {
                    // Customer returns
                    returnsInQty += e.quantityChange();
                    returnsInCost = returnsInCost.add(unit.multiply(BigDecimal.valueOf(e.quantityChange())));
                } else {
                    // Purchases (includes INITIAL_STOCK or entries with price)
                    if (e.priceAtChange() != null || e.reason() == StockChangeReason.INITIAL_STOCK) {
                        purchasesQty += e.quantityChange();
                        purchasesCost = purchasesCost.add(unit.multiply(BigDecimal.valueOf(e.quantityChange())));
                    }
                }

            } else if (e.quantityChange() < 0) {
                // === OUTBOUND: Sales, Write-offs, Returns to Supplier ===
                int out = Math.abs(e.quantityChange());

                if (RETURN_TO_SUPPLIER.contains(e.reason())) {
                    // Returning to supplier → negative purchase
                    WacIssue iss = issueAt(st, out);
                    state.put(e.itemId(), iss.state());
                    purchasesQty -= out;
                    purchasesCost = purchasesCost.subtract(iss.cost());

                } else if (WRITE_OFFS.contains(e.reason())) {
                    // Damaged, lost, expired, etc.
                    WacIssue iss = issueAt(st, out);
                    state.put(e.itemId(), iss.state());
                    writeOffQty += out;
                    writeOffCost = writeOffCost.add(iss.cost());

                } else {
                    // Default: COGS (Cost of Goods Sold)
                    WacIssue iss = issueAt(st, out);
                    state.put(e.itemId(), iss.state());
                    cogsQty += out;
                    cogsCost = cogsCost.add(iss.cost());
                }
            }
        }

        // === PHASE 3: Calculate Ending Inventory ===
        for (var st : state.values()) {
            endingQty += st.qty();
            endingValue = endingValue.add(st.avgCost().multiply(BigDecimal.valueOf(st.qty())));
        }

        // === Build Financial Summary DTO ===
        return FinancialSummaryDTO.builder()
                .method("WAC")
                .fromDate(from.toString())
                .toDate(to.toString())
                .openingQty(openingQty)
                .openingValue(openingValue)
                .purchasesQty(purchasesQty)
                .purchasesCost(purchasesCost)
                .returnsInQty(returnsInQty)
                .returnsInCost(returnsInCost)
                .cogsQty(cogsQty)
                .cogsCost(cogsCost)
                .writeOffQty(writeOffQty)
                .writeOffCost(writeOffCost)
                .endingQty(endingQty)
                .endingValue(endingValue)
                .build();
    }

    // === WAC Algorithm - Core Data Structures ===

    /** Represents current inventory state (qty + WAC) for a single item. */
    private record WacState(long qty, BigDecimal avgCost) {}

    /** Result of outbound operation (updated state + cost at WAC). */
    private record WacIssue(WacState state, BigDecimal cost) {}

    // === WAC Algorithm - Core Operations ===

    /**
     * Applies inbound stock movement and recalculates WAC.
     *
     * <p><strong>Formula</strong>:
     * <pre>
     * newWAC = (oldQty × oldWAC + inboundQty × unitCost) / (oldQty + inboundQty)
     * </pre>
     *
     * @param st current state (nullable for first purchase)
     * @param qtyIn quantity being added (positive)
     * @param unitCost unit cost of inbound stock
     * @return new state with updated quantity and recalculated WAC
     */
    private static WacState applyInbound(WacState st, int qtyIn, BigDecimal unitCost) {
        long q0 = (st == null) ? 0 : st.qty();
        BigDecimal c0 = (st == null) ? BigDecimal.ZERO : st.avgCost();
        
        long q1 = q0 + qtyIn;
        BigDecimal v0  = c0.multiply(BigDecimal.valueOf(q0));
        BigDecimal vin = unitCost.multiply(BigDecimal.valueOf(qtyIn));
        
        BigDecimal avg1 = (q1 == 0)
                ? BigDecimal.ZERO
                : v0.add(vin).divide(BigDecimal.valueOf(q1), 4, RoundingMode.HALF_UP);
        
        return new WacState(q1, avg1);
    }

    /**
     * Issues (consumes) inventory at current WAC.
     *
     * <p>WAC remains unchanged, only quantity is reduced.
     * Quantity is clamped to zero if issue exceeds available stock.
     *
     * @param st current state
     * @param qtyOut quantity being issued (positive)
     * @return issue result with updated state and cost
     */
    private static WacIssue issueAt(WacState st, int qtyOut) {
        long q0 = (st == null) ? 0 : st.qty();
        BigDecimal c0 = (st == null) ? BigDecimal.ZERO : st.avgCost();
        
        long q1 = Math.max(0, q0 - qtyOut);  // Guard against negative
        BigDecimal cost = c0.multiply(BigDecimal.valueOf(qtyOut));
        
        return new WacIssue(new WacState(q1, c0), cost);
    }
}