diff --git a/PepApi.Core/Controllers/AnalyticsController.cs b/PepApi.Core/Controllers/AnalyticsController.cs
new file mode 100644
index 0000000..08441a5
--- /dev/null
+++ b/PepApi.Core/Controllers/AnalyticsController.cs
@@ -0,0 +1,528 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using PepApi.Core.Models;
+using PepLib.Data;
+
+namespace PepApi.Core.Controllers;
+
+[ApiController]
+[Route("analytics")]
+public class AnalyticsController : ControllerBase
+{
+ private readonly PepDB _db;
+
+ // Status codes for "has been cut"
+ private static readonly int[] CutStatuses = [2, 5]; // "Has been cut", "Quote, accepted, has been cut"
+
+ public AnalyticsController(PepDB db)
+ {
+ _db = db;
+ }
+
+ ///
+ /// Get material usage summary for a date range, optionally grouped by month.
+ ///
+ [HttpGet("material-usage")]
+ public async Task> GetMaterialUsage(
+ [FromQuery] DateTime? startDate = null,
+ [FromQuery] DateTime? endDate = null,
+ [FromQuery] string? groupBy = null,
+ [FromQuery] bool cutOnly = true)
+ {
+ var start = startDate ?? DateTime.Now.AddYears(-1);
+ var end = endDate ?? DateTime.Now;
+
+ var nestsQuery = _db.NestHeaders
+ .Where(n => n.DateProgrammed != null
+ && n.DateProgrammed >= start
+ && n.DateProgrammed <= end);
+
+ if (cutOnly)
+ nestsQuery = nestsQuery.Where(n => CutStatuses.Contains(n.Status));
+
+ if (groupBy?.ToLower() == "month")
+ {
+ var nestData = await nestsQuery
+ .Select(n => new
+ {
+ n.NestName,
+ n.CopyID,
+ n.Material,
+ n.MatGrade,
+ n.MatThick,
+ n.PlateCount,
+ Year = n.DateProgrammed!.Value.Year,
+ Month = n.DateProgrammed!.Value.Month
+ })
+ .ToListAsync();
+
+ var plateData = await _db.PlateHeaders
+ .Select(p => new { p.NestName, p.CopyID, p.PlateWeight, p.TotalArea1 })
+ .ToListAsync();
+
+ var platesByNest = plateData
+ .GroupBy(p => (p.NestName, p.CopyID))
+ .ToDictionary(
+ g => g.Key,
+ g => (Weight: g.Sum(x => x.PlateWeight ?? 0), Area: g.Sum(x => x.TotalArea1 ?? 0)));
+
+ var grouped = nestData
+ .GroupBy(n => (n.Year, n.Month))
+ .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
+ .Select(g => new MaterialUsageByPeriod
+ {
+ Year = g.Key.Year,
+ Month = g.Key.Month,
+ Materials = g
+ .GroupBy(n => (n.Material, n.MatGrade, n.MatThick))
+ .Select(mg => new MaterialUsageSummary
+ {
+ MaterialNumber = int.TryParse(mg.Key.Material, out var num) ? num : 0,
+ MaterialGrade = mg.Key.MatGrade ?? "",
+ Thickness = mg.Key.MatThick,
+ NestCount = mg.Count(),
+ PlateCount = mg.Sum(x => x.PlateCount),
+ TotalWeight = mg.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Weight : 0),
+ TotalArea = mg.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Area : 0)
+ })
+ .OrderByDescending(m => m.TotalWeight)
+ .ToList()
+ });
+
+ return Ok(grouped);
+ }
+ else
+ {
+ var nestData = await nestsQuery
+ .Select(n => new
+ {
+ n.NestName,
+ n.CopyID,
+ n.Material,
+ n.MatGrade,
+ n.MatThick,
+ n.PlateCount
+ })
+ .ToListAsync();
+
+ var plateData = await _db.PlateHeaders
+ .Select(p => new { p.NestName, p.CopyID, p.PlateWeight, p.TotalArea1 })
+ .ToListAsync();
+
+ var platesByNest = plateData
+ .GroupBy(p => (p.NestName, p.CopyID))
+ .ToDictionary(
+ g => g.Key,
+ g => (Weight: g.Sum(x => x.PlateWeight ?? 0), Area: g.Sum(x => x.TotalArea1 ?? 0)));
+
+ var summary = nestData
+ .GroupBy(n => (n.Material, n.MatGrade, n.MatThick))
+ .Select(g => new MaterialUsageSummary
+ {
+ MaterialNumber = int.TryParse(g.Key.Material, out var num) ? num : 0,
+ MaterialGrade = g.Key.MatGrade ?? "",
+ Thickness = g.Key.MatThick,
+ NestCount = g.Count(),
+ PlateCount = g.Sum(x => x.PlateCount),
+ TotalWeight = g.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Weight : 0),
+ TotalArea = g.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Area : 0)
+ })
+ .OrderByDescending(m => m.TotalWeight)
+ .ToList();
+
+ return Ok(summary);
+ }
+ }
+
+ ///
+ /// Get most commonly used plate sizes, optionally filtered by material.
+ ///
+ [HttpGet("plate-sizes")]
+ public async Task>> GetPlateSizes(
+ [FromQuery] int? materialNumber = null,
+ [FromQuery] string? grade = null,
+ [FromQuery] DateTime? startDate = null,
+ [FromQuery] DateTime? endDate = null,
+ [FromQuery] bool cutOnly = true)
+ {
+ var start = startDate ?? DateTime.Now.AddYears(-1);
+ var end = endDate ?? DateTime.Now;
+
+ var nestsQuery = _db.NestHeaders
+ .Where(n => n.DateProgrammed != null
+ && n.DateProgrammed >= start
+ && n.DateProgrammed <= end);
+
+ if (cutOnly)
+ nestsQuery = nestsQuery.Where(n => CutStatuses.Contains(n.Status));
+
+ if (materialNumber.HasValue)
+ nestsQuery = nestsQuery.Where(n => n.Material == materialNumber.Value.ToString());
+
+ if (!string.IsNullOrWhiteSpace(grade))
+ nestsQuery = nestsQuery.Where(n => n.MatGrade == grade);
+
+ var nestKeys = await nestsQuery
+ .Select(n => new { n.NestName, n.CopyID })
+ .ToListAsync();
+
+ var nestKeySet = nestKeys
+ .Select(n => (n.NestName, n.CopyID))
+ .ToHashSet();
+
+ var plateData = await _db.PlateHeaders
+ .Where(p => !string.IsNullOrEmpty(p.PlateSize))
+ .Select(p => new
+ {
+ p.NestName,
+ p.CopyID,
+ p.PlateSize,
+ p.NestedLength,
+ p.NestedWidth,
+ p.PlateWeight,
+ p.TotalArea1
+ })
+ .ToListAsync();
+
+ var filteredPlates = plateData
+ .Where(p => nestKeySet.Contains((p.NestName, p.CopyID)));
+
+ var totalWeight = filteredPlates.Sum(p => p.PlateWeight ?? 0);
+
+ var plateSizes = filteredPlates
+ .GroupBy(p => p.PlateSize)
+ .Select(g => new PlateSizeUsage
+ {
+ PlateSize = g.Key ?? "",
+ Length = g.Max(p => p.NestedLength),
+ Width = g.Max(p => p.NestedWidth),
+ Count = g.Count(),
+ TotalWeight = g.Sum(p => p.PlateWeight ?? 0),
+ TotalArea = g.Sum(p => p.TotalArea1 ?? 0),
+ PercentageOfTotal = totalWeight > 0
+ ? Math.Round(g.Sum(p => p.PlateWeight ?? 0) / totalWeight * 100, 2)
+ : 0
+ })
+ .OrderByDescending(p => p.Count)
+ .ToList();
+
+ return Ok(plateSizes);
+ }
+
+ ///
+ /// Get material consumption breakdown by thickness.
+ ///
+ [HttpGet("thickness-breakdown")]
+ public async Task>> GetThicknessBreakdown(
+ [FromQuery] int? year = null,
+ [FromQuery] DateTime? startDate = null,
+ [FromQuery] DateTime? endDate = null,
+ [FromQuery] bool cutOnly = true)
+ {
+ DateTime start, end;
+
+ if (year.HasValue)
+ {
+ start = new DateTime(year.Value, 1, 1);
+ end = new DateTime(year.Value, 12, 31, 23, 59, 59);
+ }
+ else
+ {
+ start = startDate ?? DateTime.Now.AddYears(-1);
+ end = endDate ?? DateTime.Now;
+ }
+
+ var nestsQuery = _db.NestHeaders
+ .Where(n => n.DateProgrammed != null
+ && n.DateProgrammed >= start
+ && n.DateProgrammed <= end);
+
+ if (cutOnly)
+ nestsQuery = nestsQuery.Where(n => CutStatuses.Contains(n.Status));
+
+ var nestData = await nestsQuery
+ .Select(n => new
+ {
+ n.NestName,
+ n.CopyID,
+ n.MatThick,
+ n.MatGrade,
+ n.PlateCount
+ })
+ .ToListAsync();
+
+ var plateData = await _db.PlateHeaders
+ .Select(p => new { p.NestName, p.CopyID, p.PlateWeight })
+ .ToListAsync();
+
+ var platesByNest = plateData
+ .GroupBy(p => (p.NestName, p.CopyID))
+ .ToDictionary(g => g.Key, g => g.Sum(x => x.PlateWeight ?? 0));
+
+ var totalWeight = nestData.Sum(n => platesByNest.TryGetValue((n.NestName, n.CopyID), out var w) ? w : 0);
+
+ var breakdown = nestData
+ .GroupBy(n => n.MatThick)
+ .Select(g => new ThicknessBreakdown
+ {
+ Thickness = g.Key,
+ NestCount = g.Count(),
+ PlateCount = g.Sum(x => x.PlateCount),
+ TotalWeight = g.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var w) ? w : 0),
+ PercentageOfTotal = totalWeight > 0
+ ? Math.Round(g.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var w) ? w : 0) / totalWeight * 100, 2)
+ : 0,
+ MaterialGrades = g.Select(x => x.MatGrade).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList()!
+ })
+ .OrderByDescending(t => t.TotalWeight)
+ .ToList();
+
+ return Ok(breakdown);
+ }
+
+ ///
+ /// Get material usage breakdown by customer.
+ ///
+ [HttpGet("customer-usage")]
+ public async Task>> GetCustomerUsage(
+ [FromQuery] string? customerId = null,
+ [FromQuery] int months = 12,
+ [FromQuery] bool cutOnly = true)
+ {
+ var start = DateTime.Now.AddMonths(-months);
+
+ var nestsQuery = _db.NestHeaders
+ .Where(n => n.DateProgrammed != null && n.DateProgrammed >= start);
+
+ if (cutOnly)
+ nestsQuery = nestsQuery.Where(n => CutStatuses.Contains(n.Status));
+
+ if (!string.IsNullOrWhiteSpace(customerId))
+ nestsQuery = nestsQuery.Where(n => n.CustomerName == customerId || n.CustID == customerId);
+
+ var nestData = await nestsQuery
+ .Select(n => new
+ {
+ n.NestName,
+ n.CopyID,
+ n.CustomerName,
+ n.Material,
+ n.MatGrade,
+ n.MatThick,
+ n.PlateCount
+ })
+ .ToListAsync();
+
+ var plateData = await _db.PlateHeaders
+ .Select(p => new { p.NestName, p.CopyID, p.PlateWeight, p.TotalArea1 })
+ .ToListAsync();
+
+ var platesByNest = plateData
+ .GroupBy(p => (p.NestName, p.CopyID))
+ .ToDictionary(
+ g => g.Key,
+ g => (Weight: g.Sum(x => x.PlateWeight ?? 0), Area: g.Sum(x => x.TotalArea1 ?? 0)));
+
+ var customerUsage = nestData
+ .GroupBy(n => n.CustomerName)
+ .Select(cg => new CustomerMaterialUsage
+ {
+ CustomerName = cg.Key ?? "Unknown",
+ TotalNests = cg.Count(),
+ TotalWeight = cg.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Weight : 0),
+ Materials = cg
+ .GroupBy(n => (n.Material, n.MatGrade, n.MatThick))
+ .Select(mg => new MaterialUsageSummary
+ {
+ MaterialNumber = int.TryParse(mg.Key.Material, out var num) ? num : 0,
+ MaterialGrade = mg.Key.MatGrade ?? "",
+ Thickness = mg.Key.MatThick,
+ NestCount = mg.Count(),
+ PlateCount = mg.Sum(x => x.PlateCount),
+ TotalWeight = mg.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Weight : 0),
+ TotalArea = mg.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Area : 0)
+ })
+ .OrderByDescending(m => m.TotalWeight)
+ .ToList()
+ })
+ .OrderByDescending(c => c.TotalWeight)
+ .ToList();
+
+ return Ok(customerUsage);
+ }
+
+ ///
+ /// Get stock recommendations based on historical usage.
+ ///
+ [HttpGet("stock-recommendations")]
+ public async Task>> GetStockRecommendations(
+ [FromQuery] int months = 6,
+ [FromQuery] double stockMultiplier = 1.5,
+ [FromQuery] bool cutOnly = true,
+ [FromQuery] string? customerId = null,
+ [FromQuery] DateTime? startDate = null,
+ [FromQuery] DateTime? endDate = null)
+ {
+ DateTime start, end;
+ int monthsAnalyzed;
+
+ if (startDate.HasValue && endDate.HasValue)
+ {
+ start = startDate.Value;
+ end = endDate.Value;
+ monthsAnalyzed = (int)Math.Ceiling((end - start).TotalDays / 30.0);
+ }
+ else
+ {
+ start = DateTime.Now.AddMonths(-months);
+ end = DateTime.Now;
+ monthsAnalyzed = months;
+ }
+
+ var nestsQuery = _db.NestHeaders
+ .Where(n => n.DateProgrammed != null && n.DateProgrammed >= start && n.DateProgrammed <= end);
+
+ if (cutOnly)
+ nestsQuery = nestsQuery.Where(n => CutStatuses.Contains(n.Status));
+
+ if (!string.IsNullOrWhiteSpace(customerId))
+ nestsQuery = nestsQuery.Where(n => n.CustomerName == customerId || n.CustID == customerId);
+
+ var nestData = await nestsQuery
+ .Select(n => new
+ {
+ n.NestName,
+ n.CopyID,
+ n.Material,
+ n.MatGrade,
+ n.MatThick,
+ n.PlateCount
+ })
+ .ToListAsync();
+
+ var nestKeys = nestData.Select(n => (n.NestName, n.CopyID)).ToHashSet();
+
+ var plateData = await _db.PlateHeaders
+ .Select(p => new { p.NestName, p.CopyID, p.PlateSize, p.PlateWeight })
+ .ToListAsync();
+
+ // Filter plates to only those belonging to our filtered nests
+ var filteredPlates = plateData
+ .Where(p => nestKeys.Contains((p.NestName, p.CopyID)))
+ .ToList();
+
+ // Create lookup: nest -> material info
+ var nestMaterialLookup = nestData.ToDictionary(
+ n => (n.NestName, n.CopyID),
+ n => (n.Material, n.MatGrade, n.MatThick));
+
+ // Group plates by material/grade/thickness, then by plate size to get actual counts
+ var platesByMaterial = filteredPlates
+ .Select(p => new
+ {
+ p.PlateSize,
+ p.PlateWeight,
+ Material = nestMaterialLookup.TryGetValue((p.NestName, p.CopyID), out var m) ? m : default
+ })
+ .Where(p => p.Material != default)
+ .GroupBy(p => p.Material)
+ .ToDictionary(
+ g => g.Key,
+ g => (
+ TotalWeight: g.Sum(x => x.PlateWeight ?? 0),
+ TotalCount: g.Count(),
+ TopSize: g
+ .Where(x => !string.IsNullOrEmpty(x.PlateSize))
+ .GroupBy(x => x.PlateSize)
+ .OrderByDescending(sg => sg.Count())
+ .Select(sg => sg.Key)
+ .FirstOrDefault() ?? ""
+ ));
+
+ var recommendations = platesByMaterial
+ .Select(kvp =>
+ {
+ var (material, matGrade, matThick) = kvp.Key;
+ var (totalWeight, totalPlates, topPlateSize) = kvp.Value;
+
+ var avgMonthlyWeight = totalWeight / monthsAnalyzed;
+ var avgMonthlyPlates = (double)totalPlates / monthsAnalyzed;
+
+ return new StockRecommendation
+ {
+ MaterialNumber = int.TryParse(material, out var num) ? num : 0,
+ MaterialGrade = matGrade ?? "",
+ Thickness = matThick,
+ RecommendedPlateSize = topPlateSize,
+ AverageMonthlyWeight = Math.Round(avgMonthlyWeight, 2),
+ AverageMonthlyPlates = Math.Round(avgMonthlyPlates, 2),
+ MonthsAnalyzed = monthsAnalyzed,
+ SuggestedStockWeight = Math.Round(avgMonthlyWeight * stockMultiplier, 2),
+ SuggestedStockPlates = (int)Math.Ceiling(avgMonthlyPlates * stockMultiplier)
+ };
+ })
+ .Where(r => r.AverageMonthlyWeight > 0)
+ .OrderByDescending(r => r.AverageMonthlyWeight)
+ .ToList();
+
+ return Ok(recommendations);
+ }
+
+ ///
+ /// Get a quick summary of top materials used.
+ ///
+ [HttpGet("top-materials")]
+ public async Task>> GetTopMaterials(
+ [FromQuery] int count = 10,
+ [FromQuery] int months = 12,
+ [FromQuery] bool cutOnly = true)
+ {
+ var start = DateTime.Now.AddMonths(-months);
+
+ var nestsQuery = _db.NestHeaders
+ .Where(n => n.DateProgrammed != null && n.DateProgrammed >= start);
+
+ if (cutOnly)
+ nestsQuery = nestsQuery.Where(n => CutStatuses.Contains(n.Status));
+
+ var nestData = await nestsQuery
+ .Select(n => new
+ {
+ n.NestName,
+ n.CopyID,
+ n.Material,
+ n.MatGrade,
+ n.MatThick,
+ n.PlateCount
+ })
+ .ToListAsync();
+
+ var plateData = await _db.PlateHeaders
+ .Select(p => new { p.NestName, p.CopyID, p.PlateWeight, p.TotalArea1 })
+ .ToListAsync();
+
+ var platesByNest = plateData
+ .GroupBy(p => (p.NestName, p.CopyID))
+ .ToDictionary(
+ g => g.Key,
+ g => (Weight: g.Sum(x => x.PlateWeight ?? 0), Area: g.Sum(x => x.TotalArea1 ?? 0)));
+
+ var topMaterials = nestData
+ .GroupBy(n => (n.Material, n.MatGrade, n.MatThick))
+ .Select(g => new MaterialUsageSummary
+ {
+ MaterialNumber = int.TryParse(g.Key.Material, out var num) ? num : 0,
+ MaterialGrade = g.Key.MatGrade ?? "",
+ Thickness = g.Key.MatThick,
+ NestCount = g.Count(),
+ PlateCount = g.Sum(x => x.PlateCount),
+ TotalWeight = g.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Weight : 0),
+ TotalArea = g.Sum(x => platesByNest.TryGetValue((x.NestName, x.CopyID), out var v) ? v.Area : 0)
+ })
+ .OrderByDescending(m => m.TotalWeight)
+ .Take(count)
+ .ToList();
+
+ return Ok(topMaterials);
+ }
+}
diff --git a/PepApi.Core/Models/MaterialUsageSummary.cs b/PepApi.Core/Models/MaterialUsageSummary.cs
new file mode 100644
index 0000000..1581d8e
--- /dev/null
+++ b/PepApi.Core/Models/MaterialUsageSummary.cs
@@ -0,0 +1,61 @@
+namespace PepApi.Core.Models;
+
+public class MaterialUsageSummary
+{
+ public int MaterialNumber { get; set; }
+ public required string MaterialGrade { get; set; }
+ public double Thickness { get; set; }
+ public int NestCount { get; set; }
+ public int PlateCount { get; set; }
+ public double TotalWeight { get; set; }
+ public double TotalArea { get; set; }
+}
+
+public class MaterialUsageByPeriod
+{
+ public int Year { get; set; }
+ public int Month { get; set; }
+ public required List Materials { get; set; }
+}
+
+public class PlateSizeUsage
+{
+ public required string PlateSize { get; set; }
+ public double Length { get; set; }
+ public double Width { get; set; }
+ public int Count { get; set; }
+ public double TotalWeight { get; set; }
+ public double TotalArea { get; set; }
+ public double PercentageOfTotal { get; set; }
+}
+
+public class ThicknessBreakdown
+{
+ public double Thickness { get; set; }
+ public int NestCount { get; set; }
+ public int PlateCount { get; set; }
+ public double TotalWeight { get; set; }
+ public double PercentageOfTotal { get; set; }
+ public required List MaterialGrades { get; set; }
+}
+
+public class CustomerMaterialUsage
+{
+ public required string CustomerName { get; set; }
+ public required List Materials { get; set; }
+ public double TotalWeight { get; set; }
+ public int TotalNests { get; set; }
+}
+
+public class StockRecommendation
+{
+ public int MaterialNumber { get; set; }
+ public required string MaterialGrade { get; set; }
+ public double Thickness { get; set; }
+ public required string RecommendedPlateSize { get; set; }
+ public double AverageMonthlyWeight { get; set; }
+ public double AverageMonthlyPlates { get; set; }
+ public int MonthsAnalyzed { get; set; }
+ public double SuggestedStockWeight { get; set; }
+ public int SuggestedStockPlates { get; set; }
+}