Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Dapper;
|
||||
using JdeScoping.DataAccess.Configuration;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataAccess.Models;
|
||||
using JdeScoping.DataAccess.Models.Results;
|
||||
using JdeScoping.DataAccess.QueryBuilders;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SqlKata.Compilers;
|
||||
|
||||
namespace JdeScoping.DataAccess.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Main search processor service that orchestrates search execution.
|
||||
/// </summary>
|
||||
public sealed class SearchProcessor
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly ISearchQueryBuilder _queryBuilder;
|
||||
private readonly IWorkOrderTraversalService _traversalService;
|
||||
private readonly MisQueryBuilder _misQueryBuilder;
|
||||
private readonly SearchProcessingConfiguration _options;
|
||||
private readonly ILogger<SearchProcessor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of SearchProcessor.
|
||||
/// </summary>
|
||||
public SearchProcessor(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
ISearchQueryBuilder queryBuilder,
|
||||
IWorkOrderTraversalService traversalService,
|
||||
SqlServerCompiler compiler,
|
||||
IOptions<SearchProcessingConfiguration> options,
|
||||
ILogger<SearchProcessor> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_queryBuilder = queryBuilder;
|
||||
_traversalService = traversalService;
|
||||
_misQueryBuilder = new MisQueryBuilder(compiler);
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes search and returns results as async stream.
|
||||
/// </summary>
|
||||
/// <param name="model">The search model containing filter criteria.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of search results.</returns>
|
||||
public async IAsyncEnumerable<SearchResult> ExecuteSearchAsync(
|
||||
SearchModel model,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Executing search {SearchId}", model.Id);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
|
||||
// Build the search query
|
||||
var queryResult = _queryBuilder.BuildSearchQuery(model);
|
||||
|
||||
if (_options.EnableDebugSql && !string.IsNullOrEmpty(_options.DebugSqlPath))
|
||||
{
|
||||
await WriteDebugSqlAsync(model.Id, queryResult);
|
||||
}
|
||||
|
||||
// Execute setup SQL (temp tables, filter population)
|
||||
foreach (var setupSql in queryResult.TempTableSetupSql)
|
||||
{
|
||||
_logger.LogDebug("Executing setup SQL: {Sql}", setupSql[..Math.Min(100, setupSql.Length)]);
|
||||
await connection.ExecuteAsync(
|
||||
setupSql,
|
||||
queryResult.Parameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
}
|
||||
|
||||
// Execute downstream traversal
|
||||
await _traversalService.TraverseDownstreamAsync(
|
||||
connection,
|
||||
_options.MaxTraversalIterations,
|
||||
ct);
|
||||
|
||||
// Stream results using unbuffered query
|
||||
_logger.LogDebug("Executing result query");
|
||||
var reader = await connection.QueryAsync<SearchResult>(
|
||||
queryResult.Sql,
|
||||
queryResult.Parameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
|
||||
foreach (var result in reader)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Search {SearchId} completed", model.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes search and materializes all results into SearchModel.
|
||||
/// </summary>
|
||||
/// <param name="model">The search model containing filter criteria.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The SearchModel populated with results.</returns>
|
||||
public async Task<SearchModel> ExecuteSearchToModelAsync(
|
||||
SearchModel model,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Executing search {SearchId} to model", model.Id);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
|
||||
// Build the search query
|
||||
var queryResult = _queryBuilder.BuildSearchQuery(model);
|
||||
|
||||
if (_options.EnableDebugSql && !string.IsNullOrEmpty(_options.DebugSqlPath))
|
||||
{
|
||||
await WriteDebugSqlAsync(model.Id, queryResult);
|
||||
}
|
||||
|
||||
// Execute setup SQL (temp tables, filter population)
|
||||
foreach (var setupSql in queryResult.TempTableSetupSql)
|
||||
{
|
||||
_logger.LogDebug("Executing setup SQL: {Sql}", setupSql[..Math.Min(100, setupSql.Length)]);
|
||||
await connection.ExecuteAsync(
|
||||
setupSql,
|
||||
queryResult.Parameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
}
|
||||
|
||||
// Execute downstream traversal
|
||||
await _traversalService.TraverseDownstreamAsync(
|
||||
connection,
|
||||
_options.MaxTraversalIterations,
|
||||
ct);
|
||||
|
||||
// Execute result query and materialize
|
||||
_logger.LogDebug("Executing result query");
|
||||
var results = await connection.QueryAsync<SearchResult>(
|
||||
queryResult.Sql,
|
||||
queryResult.Parameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
|
||||
model.Results = results.ToList();
|
||||
_logger.LogInformation("Search {SearchId} returned {ResultCount} results", model.Id, model.Results.Count);
|
||||
|
||||
// Extract MIS data if requested
|
||||
if (model.ExtractMisData)
|
||||
{
|
||||
await ExecuteMisExtractionAsync(model, connection, ct);
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private async Task ExecuteMisExtractionAsync(
|
||||
SearchModel model,
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Extracting MIS data for search {SearchId}", model.Id);
|
||||
|
||||
// Build and execute MIS setup SQL
|
||||
var misSetupStatements = _misQueryBuilder.BuildMisExtractionSql(model);
|
||||
var misParameters = new Dictionary<string, object>();
|
||||
|
||||
if (model.MinimumDt.HasValue)
|
||||
{
|
||||
misParameters["p_MinimumDT"] = model.MinimumDt.Value;
|
||||
}
|
||||
if (model.MaximumDt.HasValue)
|
||||
{
|
||||
misParameters["p_MaximumDT"] = model.MaximumDt.Value;
|
||||
}
|
||||
|
||||
foreach (var sql in misSetupStatements)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
sql,
|
||||
misParameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
}
|
||||
|
||||
// Execute MIS result query
|
||||
var misQueryResult = _queryBuilder.BuildMisQuery(model);
|
||||
var misResults = await connection.QueryAsync<MisSearchResult>(
|
||||
misQueryResult.Sql,
|
||||
misQueryResult.Parameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
|
||||
model.MisResults = misResults.ToList();
|
||||
_logger.LogDebug("Found {MisResultCount} MIS results", model.MisResults.Count);
|
||||
|
||||
// Execute MIS non-match query
|
||||
var misNonMatchQueryResult = _queryBuilder.BuildMisNonMatchQuery(model);
|
||||
var misNonMatchResults = await connection.QueryAsync<MisNonMatchSearchResult>(
|
||||
misNonMatchQueryResult.Sql,
|
||||
misNonMatchQueryResult.Parameters,
|
||||
commandTimeout: _options.QueryTimeoutSeconds);
|
||||
|
||||
model.MisNonMatchResults = misNonMatchResults.ToList();
|
||||
_logger.LogDebug("Found {MisNonMatchCount} MIS non-match results", model.MisNonMatchResults.Count);
|
||||
}
|
||||
|
||||
private async Task WriteDebugSqlAsync(int searchId, SearchQueryResult queryResult)
|
||||
{
|
||||
try
|
||||
{
|
||||
var debugPath = Path.Combine(_options.DebugSqlPath!, $"search_{searchId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.sql");
|
||||
var content = string.Join("\r\n\r\n-- ========================================\r\n\r\n",
|
||||
queryResult.TempTableSetupSql.Append(queryResult.Sql));
|
||||
await File.WriteAllTextAsync(debugPath, content);
|
||||
_logger.LogDebug("Debug SQL written to {Path}", debugPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write debug SQL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Dapper;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.DataAccess.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for traversing downstream work orders iteratively.
|
||||
/// </summary>
|
||||
public sealed class WorkOrderTraversalService : IWorkOrderTraversalService
|
||||
{
|
||||
private readonly ILogger<WorkOrderTraversalService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of WorkOrderTraversalService.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public WorkOrderTraversalService(ILogger<WorkOrderTraversalService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task TraverseDownstreamAsync(
|
||||
SqlConnection connection,
|
||||
int maxIterations = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Starting downstream work order traversal (max {MaxIterations} iterations)", maxIterations);
|
||||
|
||||
// The traversal SQL iteratively finds work orders that received material from flagged work orders
|
||||
const string traversalSql = """
|
||||
--Add downlevel work orders that were issued material from flagged work orders
|
||||
DECLARE @c_MAX_RUNS INT = @p_MaxIterations;
|
||||
DECLARE @v_NumWO INT = -1;
|
||||
DECLARE @v_NewNumWO INT;
|
||||
DECLARE @v_NumRuns INT = 0;
|
||||
|
||||
WHILE(1 = 1) BEGIN
|
||||
SET @v_NumWO = @v_NewNumWO;
|
||||
|
||||
--Add any work orders issued material from flagged work orders (parts list)
|
||||
WITH CWO_D AS(
|
||||
SELECT DISTINCT wo.WorkOrderNumber,
|
||||
wo.LotNumber,
|
||||
wo.BranchCode,
|
||||
wo.ShortItemNumber
|
||||
FROM dbo.WorkOrderComponent AS woc INNER JOIN
|
||||
dbo.WorkOrder AS wo ON (woc.WorkOrderNumber = wo.WorkOrderNumber) INNER JOIN
|
||||
#Temp_WO AS t_wo ON (woc.LotNumber = t_wo.LotNumber AND woc.ShortItemNumber = t_wo.ShortItemNumber)
|
||||
)
|
||||
MERGE #Temp_WO AS TARGET
|
||||
USING CWO_D AS SOURCE
|
||||
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber)
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET TARGET.PartsList = 1
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, PartsList)
|
||||
VALUES (SOURCE.WorkOrderNumber, COALESCE(SOURCE.LotNumber, CAST(SOURCE.WorkOrderNumber AS VARCHAR(8))), SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
|
||||
|
||||
--Add any work orders issued material from flagged work orders (CARDEX)
|
||||
WITH CWO_D AS(
|
||||
SELECT DISTINCT wo.WorkOrderNumber,
|
||||
wo.LotNumber,
|
||||
wo.BranchCode,
|
||||
wo.ShortItemNumber
|
||||
FROM dbo.LotUsage AS lu INNER JOIN
|
||||
dbo.WorkOrder AS wo ON (lu.WorkOrderNumber = wo.WorkOrderNumber) INNER JOIN
|
||||
#Temp_WO AS t_wo ON (lu.LotNumber = t_wo.LotNumber AND lu.ShortItemNumber = t_wo.ShortItemNumber)
|
||||
)
|
||||
MERGE #Temp_WO AS TARGET
|
||||
USING CWO_D AS SOURCE
|
||||
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber)
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET TARGET.CARDEX = 1
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, CARDEX)
|
||||
VALUES (SOURCE.WorkOrderNumber, COALESCE(SOURCE.LotNumber, CAST(SOURCE.WorkOrderNumber AS VARCHAR(8))), SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
|
||||
|
||||
--Add any work orders split from flagged work orders
|
||||
WITH SP_WO AS
|
||||
(
|
||||
SELECT DISTINCT wo.WorkOrderNumber,
|
||||
wo.LotNumber,
|
||||
wo.BranchCode,
|
||||
wo.ShortItemNumber
|
||||
FROM dbo.WorkOrder AS wo INNER JOIN
|
||||
#Temp_WO AS tw_o ON (wo.ParentWorkOrderNumber = CAST(tw_o.WorkOrderNumber AS VARCHAR(8)) AND wo.BranchCode = tw_o.BranchCode)
|
||||
)
|
||||
MERGE #Temp_WO AS TARGET
|
||||
USING SP_WO AS SOURCE
|
||||
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber AND TARGET.BranchCode = SOURCE.BranchCode)
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET TARGET.SplitOrder = 1
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, SplitOrder)
|
||||
VALUES (SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
|
||||
|
||||
SELECT @v_NewNumWO = COUNT(*) FROM #Temp_WO;
|
||||
SET @v_NumRuns = @v_NumRuns + 1;
|
||||
|
||||
IF(@v_NumWO = @v_NewNumWO OR @v_NumRuns = @c_MAX_RUNS) BEGIN
|
||||
BREAK;
|
||||
END
|
||||
END;
|
||||
|
||||
SELECT @v_NumRuns AS IterationsCompleted, @v_NewNumWO AS TotalWorkOrders;
|
||||
""";
|
||||
|
||||
var result = await connection.QuerySingleAsync<(int IterationsCompleted, int TotalWorkOrders)>(
|
||||
traversalSql,
|
||||
new { p_MaxIterations = maxIterations },
|
||||
commandTimeout: 600);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Downstream traversal completed in {Iterations} iterations, found {TotalWorkOrders} total work orders",
|
||||
result.IterationsCompleted,
|
||||
result.TotalWorkOrders);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user