feat(analytics): add material usage analytics endpoints

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-25 14:14:48 -05:00
parent bb4ba9c973
commit b3ba888c2e
2 changed files with 589 additions and 0 deletions

View File

@@ -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;
}
/// <summary>
/// Get material usage summary for a date range, optionally grouped by month.
/// </summary>
[HttpGet("material-usage")]
public async Task<ActionResult<object>> 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);
}
}
/// <summary>
/// Get most commonly used plate sizes, optionally filtered by material.
/// </summary>
[HttpGet("plate-sizes")]
public async Task<ActionResult<List<PlateSizeUsage>>> 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);
}
/// <summary>
/// Get material consumption breakdown by thickness.
/// </summary>
[HttpGet("thickness-breakdown")]
public async Task<ActionResult<List<ThicknessBreakdown>>> 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);
}
/// <summary>
/// Get material usage breakdown by customer.
/// </summary>
[HttpGet("customer-usage")]
public async Task<ActionResult<List<CustomerMaterialUsage>>> 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);
}
/// <summary>
/// Get stock recommendations based on historical usage.
/// </summary>
[HttpGet("stock-recommendations")]
public async Task<ActionResult<List<StockRecommendation>>> 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);
}
/// <summary>
/// Get a quick summary of top materials used.
/// </summary>
[HttpGet("top-materials")]
public async Task<ActionResult<List<MaterialUsageSummary>>> 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);
}
}

View File

@@ -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<MaterialUsageSummary> 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<string> MaterialGrades { get; set; }
}
public class CustomerMaterialUsage
{
public required string CustomerName { get; set; }
public required List<MaterialUsageSummary> 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; }
}