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.
493 lines
17 KiB
C#
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();
|
|
}
|
|
}
|