From b3ba888c2e721e1ec032f4e49b0a5ca688a0a943 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 25 Nov 2025 14:14:48 -0500 Subject: [PATCH] feat(analytics): add material usage analytics endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AnalyticsController with endpoints for tracking material consumption: - /analytics/material-usage - usage summary with optional monthly grouping - /analytics/plate-sizes - commonly used plate size analysis - /analytics/thickness-breakdown - consumption by material thickness - /analytics/customer-usage - material breakdown per customer - /analytics/stock-recommendations - stock level suggestions based on history - /analytics/top-materials - quick summary of most used materials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Controllers/AnalyticsController.cs | 528 ++++++++++++++++++ PepApi.Core/Models/MaterialUsageSummary.cs | 61 ++ 2 files changed, 589 insertions(+) create mode 100644 PepApi.Core/Controllers/AnalyticsController.cs create mode 100644 PepApi.Core/Models/MaterialUsageSummary.cs 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; } +}