Files
PepApi.Core/PepApi.Core/Controllers/NestsController.cs
T
aj 1d4f64726b fix: resolve 500s on nest-details endpoints under Docker deployment
NestReader opened .pep files with FileStream(path, FileMode.Open), which
defaults to FileAccess.ReadWrite. The forge deployment bind-mounts
/mnt/pep-nest read-only, so every read-only open was rejected with
"Read-only file system" (EROFS) even though the code never writes.
Pass FileAccess.Read/FileShare.Read explicitly, matching DrawingReader
and ZipHelper elsewhere in PepLib.Core.

Once that was fixed, a second pre-existing bug surfaced: GetNestDetailsAsync
matched DB rows by NestName AND a literal Path comparison built from the
container's local mount path. NestHeader.Path stores the original Windows
UNC share path (e.g. \REMCOSRV0\pep nest\) from when PepApi ran directly
against the network share, so the comparison can never match post-Docker.
Match by NestName alone (ordered by most recent), consistent with the
fallback lookup already used elsewhere in NestsController.
2026-06-30 15:00:22 -04:00

493 lines
17 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PepApi.Core.Configuration;
using PepApi.Core.Models;
using PepLib.Data;
using PepLib.Models;
using Plate = PepApi.Core.Models.Plate;
namespace PepApi.Core.Controllers;
[ApiController]
[Route("nests")]
public class NestsController : ControllerBase
{
private readonly PepDB _db;
private readonly string _nestDirectory;
public NestsController(PepDB db, IOptions<PepSettings> settings)
{
_db = db;
_nestDirectory = settings.Value.NestDirectory;
}
[HttpGet("{year:int:regex(^\\d{{4}}$)}")]
public async Task<ActionResult<List<NestSummary>>> GetNests(int year, [FromQuery] NestFilterData? filter)
{
if (year == 0)
year = DateTime.Now.Year;
var nestHeaders = await _db.NestHeaders
.Where(n => n.DateProgrammed != null && n.DateProgrammed.Value.Year == year)
.ToListAsync();
var nestSummaries = nestHeaders.Select(ConvertFrom).ToList();
if (filter != null)
{
return Ok(filter.Apply(nestSummaries.AsQueryable()).ToList());
}
return Ok(nestSummaries);
}
private static NestSummary ConvertFrom(NestHeader nestHeader)
{
return new NestSummary
{
Comments = nestHeader.Comments,
Customer = nestHeader.CustomerName,
DateCreated = nestHeader.DateProgrammed!.Value,
DateLastModified = nestHeader.ModifiedDate!.Value,
HasErrors = !nestHeader.Errors.IsNullOrWhiteSpace(),
MaterialGrade = nestHeader.MatGrade,
MaterialNumber = int.Parse(nestHeader.Material ?? "0"),
Name = nestHeader.NestName,
Notes = nestHeader.Remarks,
ProgrammedBy = nestHeader.Programmer,
Revision = nestHeader.UserDefined1,
Status = PepHelper.GetStatus(nestHeader.Status),
Application = PepHelper.GetApplication(nestHeader.Application)
};
}
[HttpGet("{year:int:regex(^\\d{{4}}$)}/{nestName}")]
public async Task<ActionResult<NestDetails>> GetNestInfoByYear(int year, string nestName)
{
var nestFile = await GetNestPathAsync(nestName, year);
if (nestFile == null)
return NotFound(new { message = "Nest not found" });
var details = await GetNestDetailsAsync(nestFile);
return Ok(details);
}
[HttpGet("{nestName}")]
public async Task<ActionResult<NestDetails>> GetNestInfo(string nestName)
{
var nestFile = await GetNestPathAsync(nestName);
if (nestFile == null)
return NotFound(new { message = "Nest not found" });
var details = await GetNestDetailsAsync(nestFile);
return Ok(details);
}
[HttpGet("{year:int:regex(^\\d{{4}}$)}/{nestName}/download")]
public async Task<IActionResult> DownloadFileByYear(int year, string nestName)
{
var filePath = await GetNestPathAsync(nestName, year);
if (filePath == null)
return NotFound(new { message = "Nest not found" });
var bytes = await System.IO.File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = "application/octet-stream";
return File(bytes, mimeType, fileName);
}
[HttpGet("{nestName:regex(^(?!\\d{{4}}$).+)}/download")]
public async Task<IActionResult> DownloadFile(string nestName)
{
var filePath = await GetNestPathAsync(nestName);
if (filePath == null)
return NotFound(new { message = "Nest not found" });
var bytes = await System.IO.File.ReadAllBytesAsync(filePath);
var fileName = Path.GetFileName(filePath);
var mimeType = "application/octet-stream";
return File(bytes, mimeType, fileName);
}
[HttpGet("{year:int:regex(^\\d{{4}}$)}/{nestName}/plates")]
public async Task<ActionResult<List<Plate>>> GetPlatesByYear(int year, string nestName)
{
var nestFile = await GetNestPathAsync(nestName, year);
if (nestFile == null)
return NotFound(new { message = "Nest not found" });
var nest = Nest.Load(nestFile);
var plates = PepHelper.GetPlates(nest);
var combined = PepHelper.CombineLikePlates(plates);
return Ok(combined);
}
[HttpGet("{nestName:regex(^(?!\\d{{4}}$).+)}/plates")]
public async Task<ActionResult<List<Plate>>> GetPlates(string nestName)
{
var nestFile = await GetNestPathAsync(nestName);
if (nestFile == null)
return NotFound(new { message = "Nest not found" });
var nest = Nest.Load(nestFile);
var plates = PepHelper.GetPlates(nest);
var combined = PepHelper.CombineLikePlates(plates);
return Ok(combined);
}
/// <summary>
/// Search for parts across nests by part name.
/// If year is not specified, searches across all years.
/// </summary>
[HttpGet("parts/search")]
public async Task<ActionResult<PartSearchResponse>> SearchParts(
[FromQuery] string search,
[FromQuery] int? year = null,
[FromQuery] string? customer = null,
[FromQuery] int limit = 100)
{
if (string.IsNullOrWhiteSpace(search))
return BadRequest(new { message = "Search term is required" });
var result = await SearchPartsInternalAsync(search, year, customer, limit);
return Ok(result);
}
/// <summary>
/// Batch search for parts across nests by multiple part names.
/// Returns results grouped by search term.
/// </summary>
[HttpPost("parts/search/batch")]
public async Task<ActionResult<BatchPartSearchResponse>> SearchPartsBatch(
[FromBody] BatchPartSearchRequest request)
{
if (request.SearchTerms == null || request.SearchTerms.Count == 0)
return BadRequest(new { message = "At least one search term is required" });
var results = new List<PartSearchResponse>();
foreach (var searchTerm in request.SearchTerms.Distinct())
{
if (string.IsNullOrWhiteSpace(searchTerm))
continue;
var result = await SearchPartsInternalAsync(
searchTerm,
request.Year,
request.Customer,
request.LimitPerTerm);
results.Add(result);
}
return Ok(new BatchPartSearchResponse
{
TotalSearchTerms = results.Count,
TotalMatches = results.Sum(r => r.TotalMatches),
TotalNests = results.SelectMany(r => r.Results).Select(r => r.NestName).Distinct().Count(),
Results = results
});
}
private async Task<PartSearchResponse> SearchPartsInternalAsync(
string search,
int? year,
string? customer,
int limit)
{
var searchUpper = search.Trim().ToUpper();
// Get nest headers - filter by year only if specified
var nestsQuery = _db.NestHeaders
.Where(n => n.DateProgrammed != null);
if (year.HasValue)
nestsQuery = nestsQuery.Where(n => n.DateProgrammed!.Value.Year == year.Value);
if (!string.IsNullOrWhiteSpace(customer))
{
var customerUpper = customer.Trim().ToUpper();
nestsQuery = nestsQuery.Where(n =>
(n.CustomerName != null && n.CustomerName.ToUpper().Contains(customerUpper)) ||
(n.CustID != null && n.CustID.ToUpper().Contains(customerUpper)));
}
var nests = await nestsQuery
.Select(n => new
{
n.NestName,
n.CopyID,
n.CustomerName,
n.Comments,
n.Material,
n.MatGrade,
n.MatThick,
n.Status,
n.DateProgrammed,
n.ModifiedDate,
n.Application
})
.ToListAsync();
if (!nests.Any())
{
return new PartSearchResponse
{
SearchTerm = search,
Year = year,
TotalMatches = 0,
TotalNests = 0,
ResultsReturned = 0,
Results = []
};
}
var nestKeys = nests.Select(n => (n.NestName, n.CopyID)).ToHashSet();
// Search for parts matching the search term
var plateDetails = await _db.PlateDetails
.Where(p => p.Drawing != null && p.Drawing.ToUpper().Contains(searchUpper))
.Select(p => new
{
p.NestName,
p.CopyID,
p.Drawing,
p.QtyNstd,
p.QtyReq
})
.ToListAsync();
// Filter to only parts in our target nests
var matchingParts = plateDetails
.Where(p => nestKeys.Contains((p.NestName, p.CopyID)))
.ToList();
// Get material descriptions for materials used in matching nests
var materialNumbers = nests
.Select(n => n.Material)
.Where(m => !string.IsNullOrWhiteSpace(m))
.Distinct()
.ToList();
var materialDescriptions = await _db.MaterialHeaders
.Where(m => materialNumbers.Contains(m.Material))
.Select(m => new { m.Material, m.Description })
.ToListAsync();
var materialDescriptionLookup = materialDescriptions
.GroupBy(m => m.Material)
.ToDictionary(g => g.Key, g => g.First().Description);
// Group by nest and part name
var nestLookup = nests.ToDictionary(n => (n.NestName, n.CopyID));
var allResults = matchingParts
.GroupBy(p => (p.NestName, p.CopyID, p.Drawing))
.Select(g =>
{
var nest = nestLookup[(g.Key.NestName, g.Key.CopyID)];
var materialDesc = nest.Material != null && materialDescriptionLookup.TryGetValue(nest.Material, out var desc)
? desc
: "";
return new PartSearchResult
{
PartName = g.Key.Drawing ?? "",
NestName = g.Key.NestName,
Status = PepHelper.GetStatus(nest.Status),
Customer = nest.CustomerName ?? "",
Comments = nest.Comments ?? "",
MaterialNumber = int.TryParse(nest.Material, out var num) ? num : 0,
MaterialGrade = nest.MatGrade ?? "",
MaterialDescription = materialDesc,
Thickness = nest.MatThick,
DateProgrammed = nest.DateProgrammed!.Value,
DateLastModified = nest.ModifiedDate ?? nest.DateProgrammed!.Value,
QtyNested = g.Sum(x => x.QtyNstd ?? 0),
QtyRequired = g.Max(x => x.QtyReq ?? 0),
Application = PepHelper.GetApplication(nest.Application)
};
})
.OrderByDescending(r => r.DateProgrammed)
.ThenBy(r => r.PartName)
.ToList();
var limitedResults = limit > 0 ? allResults.Take(limit).ToList() : allResults;
return new PartSearchResponse
{
SearchTerm = search,
Year = year,
TotalMatches = allResults.Count,
TotalNests = allResults.Select(r => r.NestName).Distinct().Count(),
ResultsReturned = limitedResults.Count,
Results = limitedResults
};
}
private async Task<string?> GetNestPathAsync(string nestName, int? year = null)
{
// If year is specified, look directly in that year's directory
if (year.HasValue)
{
// 2025+ nests use .pep in flat directory
if (year.Value >= 2025)
{
var pepPath = Path.Combine(_nestDirectory, nestName + ".pep");
if (System.IO.File.Exists(pepPath))
return pepPath;
var pepMatch = FindFileByPrefix(_nestDirectory, nestName, ".pep");
if (pepMatch != null)
return pepMatch;
}
// Older nests use .zip in year subdirectory
var yearDir = Path.Combine(_nestDirectory, year.Value.ToString());
var zipPath = Path.Combine(yearDir, nestName + ".zip");
if (System.IO.File.Exists(zipPath))
return zipPath;
var zipMatch = FindFileByPrefix(yearDir, nestName, ".zip");
if (zipMatch != null)
return zipMatch;
return null;
}
// No year specified - try flat directory first (new 2025+ nests use .pep extension)
var flatPepPath = Path.Combine(_nestDirectory, nestName + ".pep");
if (System.IO.File.Exists(flatPepPath))
return flatPepPath;
var flatPepMatch = FindFileByPrefix(_nestDirectory, nestName, ".pep");
if (flatPepMatch != null)
return flatPepMatch;
// Fall back to year-based directory lookup for older nests
var upperName = nestName.ToUpper();
var nestHeader = await _db.NestHeaders
.Where(n => (n.NestName.ToUpper() == upperName || n.NestName.ToUpper().StartsWith(upperName + "-"))
&& n.DateProgrammed != null)
.OrderByDescending(n => n.DateProgrammed)
.FirstOrDefaultAsync();
if (nestHeader == null)
return null;
var fullName = nestHeader.NestName;
var dbYear = nestHeader.DateProgrammed!.Value.Year;
// Older nests used .zip extension
var yearZipPath = Path.Combine(_nestDirectory, dbYear.ToString(), fullName + ".zip");
if (System.IO.File.Exists(yearZipPath))
return yearZipPath;
return null;
}
private static string? FindFileByPrefix(string directory, string prefix, string extension)
{
if (!Directory.Exists(directory))
return null;
var pattern = prefix + "-*" + extension;
var matches = Directory.GetFiles(directory, pattern);
return matches.Length > 0 ? matches.Order().First() : null;
}
private async Task<NestDetails> GetNestDetailsAsync(string nestFilePath)
{
var nest = Nest.Load(nestFilePath);
var name = Path.GetFileNameWithoutExtension(nestFilePath).ToUpper();
var info = await _db.NestHeaders
.Where(n => n.NestName.ToUpper() == name)
.OrderByDescending(n => n.DateProgrammed)
.FirstOrDefaultAsync();
if (info == null)
throw new Exception("Nest header not found in database");
var details = new NestDetails
{
Name = info.NestName,
DateCreated = info.DateProgrammed!.Value,
Status = PepHelper.GetStatus(info.Status),
DateLastModified = info.ModifiedDate!.Value,
Material = PepHelper.GetMaterial(nest),
Plates = PepHelper.GetPlates(nest),
Parts = await GetPartsFromDbAsync(info),
NumberOfTestSquares = PepHelper.GetTestSquareCount(nest),
AreTestSquaresOutOfSequence = !PepHelper.AreTestSquaresInCorrectOrder(nest),
Customer = info.CustomerName,
Comments = info.Comments,
HasErrors = !info.Errors.IsNullOrWhiteSpace(),
Notes = info.Remarks,
ProgrammedBy = info.Programmer,
Revision = info.UserDefined1,
Application = PepHelper.GetApplication(info.Application)
};
return details;
}
// Build Parts using database PlateDetail.QtyNstd instead of calculating from files
private async Task<List<PepApi.Core.Models.Part>> GetPartsFromDbAsync(NestHeader info)
{
// Filter by NestName and CopyID to get the correct set for this program
var nestName = info.NestName;
var copyId = info.CopyID;
// Avoid method calls on columns in the WHERE clause to keep index usage optimal
var plateDetails = await _db.PlateDetails
.Where(p => p.NestName == nestName && p.CopyID == copyId)
.Select(p => new { p.Drawing, p.QtyNstd, p.QtyReq, p.PlateNumber })
.ToListAsync();
// Group by drawing name, aggregate nested and required quantities
var groups = plateDetails
.Where(p => !string.IsNullOrWhiteSpace(p.Drawing))
.GroupBy(p => p.Drawing.Trim().ToUpper());
var parts = new List<PepApi.Core.Models.Part>();
foreach (var g in groups)
{
var qtyNested = g.Sum(x => x.QtyNstd ?? 0);
var qtyReq = g.Select(x => x.QtyReq ?? 0).DefaultIfEmpty(0).Max();
// Use PlateNumber from DB; convert to 0-based index to align with previous behavior
var nestedOn = g
.Where(x => (x.QtyNstd ?? 0) > 0 && x.PlateNumber.HasValue)
.Select(x => x.PlateNumber!.Value - 1)
.Distinct()
.OrderBy(i => i)
.ToArray();
// Preserve the original case for Name if available
var originalName = g.Select(x => x.Drawing).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? g.Key;
parts.Add(new PepApi.Core.Models.Part
{
Name = originalName,
QtyNested = qtyNested,
QtyRequired = qtyReq,
NestedOn = nestedOn
});
}
return parts.OrderBy(p => p.Name).ToList();
}
}