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:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,58 @@
namespace JdeScoping.DataAccess.Attributes;
/// <summary>
/// Excel output column specification attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class OutputColumnAttribute : Attribute
{
/// <summary>
/// Standard format.
/// </summary>
public const string StdFormat = "@";
/// <summary>
/// Standard date format.
/// </summary>
public const string DateFormat = "[$-409]MM/dd/yyyy;@";
/// <summary>
/// Standard timestamp format.
/// </summary>
public const string TimestampFormat = "[$-409]m/d/yy h:mm AM/PM;@";
/// <summary>
/// Wrapped text column default width.
/// </summary>
public const double WrappedColumnWidth = 65;
/// <summary>
/// Order to display column.
/// </summary>
public int Order { get; set; }
/// <summary>
/// Override text to display for column header.
/// </summary>
public string HeaderText { get; set; } = string.Empty;
/// <summary>
/// Column format (Excel formatting string).
/// </summary>
public string Format { get; set; } = StdFormat;
/// <summary>
/// Whether or not width should be set automatically.
/// </summary>
public bool AutoWidth { get; set; } = true;
/// <summary>
/// Manually set width (only used if AutoWidth = FALSE).
/// </summary>
public double Width { get; set; }
/// <summary>
/// Whether or not text should be wrapped.
/// </summary>
public bool WrapText { get; set; }
}
@@ -0,0 +1,23 @@
namespace JdeScoping.DataAccess.Attributes;
/// <summary>
/// Excel output table specification attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class OutputTableAttribute : Attribute
{
/// <summary>
/// Output tab name in Excel.
/// </summary>
public string TabName { get; set; } = string.Empty;
/// <summary>
/// Table name for the Excel table.
/// </summary>
public string TableName { get; set; } = string.Empty;
/// <summary>
/// Whether or not merged header should be shown.
/// </summary>
public bool ShowHeader { get; set; }
}
@@ -0,0 +1,52 @@
namespace JdeScoping.DataAccess.Configuration;
/// <summary>
/// Configuration options for the data access layer.
/// </summary>
public class DataAccessOptions
{
/// <summary>
/// Configuration section name for binding.
/// </summary>
public const string SectionName = "DataAccess";
/// <summary>
/// Default timeout for database queries in seconds.
/// </summary>
public int DefaultTimeoutSeconds { get; set; } = 600;
/// <summary>
/// Timeout for lot usage queries in seconds (very large dataset).
/// </summary>
public int LotUsageTimeoutSeconds { get; set; } = 999999;
/// <summary>
/// Timeout for MIS data queries in seconds.
/// </summary>
public int MisDataTimeoutSeconds { get; set; } = 60000;
/// <summary>
/// Timeout for index rebuild operations in seconds.
/// </summary>
public int RebuildIndexTimeoutSeconds { get; set; } = 600;
/// <summary>
/// JDE production schema name (e.g., PRODDTA).
/// </summary>
public string ProductionSchema { get; set; } = "PRODDTA";
/// <summary>
/// JDE archive schema name (e.g., ARCDTAPD).
/// </summary>
public string ArchiveSchema { get; set; } = "ARCDTAPD";
/// <summary>
/// JDE stage schema name (e.g., JDESTAGE).
/// </summary>
public string StageSchema { get; set; } = "JDESTAGE";
/// <summary>
/// Enable detailed SQL logging for debugging.
/// </summary>
public bool EnableDetailedLogging { get; set; } = false;
}
@@ -0,0 +1,32 @@
namespace JdeScoping.DataAccess.Configuration;
/// <summary>
/// Configuration options for search processing.
/// </summary>
public class SearchProcessingConfiguration
{
/// <summary>
/// Configuration section name in appsettings.json.
/// </summary>
public const string SectionName = "SearchProcessing";
/// <summary>
/// Query timeout in seconds for search execution.
/// </summary>
public int QueryTimeoutSeconds { get; set; } = 600;
/// <summary>
/// Maximum downstream traversal iterations.
/// </summary>
public int MaxTraversalIterations { get; set; } = 20;
/// <summary>
/// Enable debug SQL logging.
/// </summary>
public bool EnableDebugSql { get; set; } = false;
/// <summary>
/// Path to write debug SQL files (when EnableDebugSql is true).
/// </summary>
public string? DebugSqlPath { get; set; }
}
@@ -0,0 +1,130 @@
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.DataAccess;
/// <summary>
/// Factory for creating database connections to all data sources.
/// </summary>
public class DbConnectionFactory : IDbConnectionFactory
{
private readonly IConfiguration _configuration;
private readonly ILogger<DbConnectionFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DbConnectionFactory"/> class.
/// </summary>
/// <param name="configuration">Application configuration.</param>
/// <param name="logger">Logger instance.</param>
public DbConnectionFactory(IConfiguration configuration, ILogger<DbConnectionFactory> logger)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<SqlConnection> CreateLotFinderConnectionAsync(CancellationToken ct = default)
{
const string dataSource = "LotFinderDB";
var connectionString = _configuration.GetConnectionString(dataSource);
if (string.IsNullOrEmpty(connectionString))
{
throw new ConnectionException(
$"{dataSource}: Connection string not found in configuration.",
dataSource);
}
try
{
_logger.LogDebug("Creating connection to {DataSource}", dataSource);
var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct).ConfigureAwait(false);
_logger.LogDebug("Successfully connected to {DataSource}", dataSource);
return connection;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["DataSource"] = dataSource,
["Operation"] = "CreateConnection"
}))
{
_logger.LogError(ex, "Failed to connect to {DataSource}", dataSource);
}
throw new ConnectionException(
$"{dataSource}: Failed to open connection to database.",
dataSource,
ex);
}
}
/// <inheritdoc/>
public async Task<OracleConnection> CreateJdeConnectionAsync(CancellationToken ct = default)
{
return await CreateOracleConnectionAsync("JDE", ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<OracleConnection> CreateJdeStageConnectionAsync(CancellationToken ct = default)
{
return await CreateOracleConnectionAsync("JDEStage", ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<OracleConnection> CreateCmsConnectionAsync(CancellationToken ct = default)
{
return await CreateOracleConnectionAsync("CMS", ct).ConfigureAwait(false);
}
private async Task<OracleConnection> CreateOracleConnectionAsync(string dataSource, CancellationToken ct)
{
var connectionString = _configuration.GetConnectionString(dataSource);
if (string.IsNullOrEmpty(connectionString))
{
throw new ConnectionException(
$"{dataSource}: Connection string not found in configuration.",
dataSource);
}
try
{
_logger.LogDebug("Creating connection to {DataSource}", dataSource);
var connection = new OracleConnection(connectionString);
await connection.OpenAsync(ct).ConfigureAwait(false);
_logger.LogDebug("Successfully connected to {DataSource}", dataSource);
return connection;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["DataSource"] = dataSource,
["Operation"] = "CreateConnection"
}))
{
_logger.LogError(ex, "Failed to connect to {DataSource}", dataSource);
}
throw new ConnectionException(
$"{dataSource}: Failed to open connection to database.",
dataSource,
ex);
}
}
}
@@ -0,0 +1,92 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Options;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Configuration;
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.QueryBuilders;
using JdeScoping.DataAccess.Repositories;
using JdeScoping.DataAccess.Services;
using Microsoft.Extensions.Configuration;
using SqlKata.Compilers;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for registering data access services.
/// </summary>
public static class DataAccessDependencyInjection
{
/// <summary>
/// Adds data access layer services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration instance.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDataAccess(
this IServiceCollection services,
IConfiguration configuration)
{
// Register configuration options
services.Configure<DataAccessOptions>(
configuration.GetSection(DataAccessOptions.SectionName));
services.Configure<SearchProcessingOptions>(
configuration.GetSection(SearchProcessingOptions.SectionName));
services.Configure<SearchProcessingConfiguration>(
configuration.GetSection(SearchProcessingConfiguration.SectionName));
// Register connection factory as singleton
services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>();
// Register repositories as scoped (per-request lifetime)
services.AddScoped<ILotFinderRepository, LotFinderRepository>();
services.AddScoped<IJdeRepository, JdeRepository>();
services.AddScoped<ICmsRepository, CmsRepository>();
// Register SqlKata compiler (singleton, thread-safe)
services.AddSingleton<SqlServerCompiler>();
// Register filter handlers (scoped - one per request)
services.AddScoped<IFilterHandler, WorkOrderFilterHandler>();
services.AddScoped<IFilterHandler, ItemNumberFilterHandler>();
services.AddScoped<IFilterHandler, ProfitCenterFilterHandler>();
services.AddScoped<IFilterHandler, WorkCenterFilterHandler>();
services.AddScoped<IFilterHandler, OperatorFilterHandler>();
services.AddScoped<IFilterHandler, ComponentLotFilterHandler>();
services.AddScoped<IFilterHandler, ItemOperationMisFilterHandler>();
services.AddScoped<IFilterHandler, TimespanFilterHandler>();
// Register query builder (scoped)
services.AddScoped<ISearchQueryBuilder, SqlKataSearchQueryBuilder>();
// Register search processing services (scoped)
services.AddScoped<IWorkOrderTraversalService, WorkOrderTraversalService>();
services.AddScoped<SearchProcessor>();
return services;
}
/// <summary>
/// Adds data access layer services with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Action to configure options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddDataAccess(
this IServiceCollection services,
Action<DataAccessOptions> configureOptions)
{
// Register configuration options via action
services.Configure(configureOptions);
// Register connection factory as singleton
services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>();
// Register repositories as scoped (per-request lifetime)
services.AddScoped<ILotFinderRepository, LotFinderRepository>();
services.AddScoped<IJdeRepository, JdeRepository>();
services.AddScoped<ICmsRepository, CmsRepository>();
return services;
}
}
@@ -0,0 +1,24 @@
namespace JdeScoping.DataAccess.Exceptions;
/// <summary>
/// Exception thrown when a database connection fails.
/// </summary>
public class ConnectionException : DataAccessException
{
/// <summary>
/// The data source identifier (e.g., "JDE", "CMS", "LotFinderDB").
/// </summary>
public string? DataSource { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="dataSource">The data source that failed to connect.</param>
/// <param name="inner">The inner exception.</param>
public ConnectionException(string message, string? dataSource, Exception? inner = null)
: base(message, operation: "CreateConnection", repository: dataSource, inner: inner)
{
DataSource = dataSource;
}
}
@@ -0,0 +1,35 @@
namespace JdeScoping.DataAccess.Exceptions;
/// <summary>
/// Base exception for all data access layer errors.
/// </summary>
public class DataAccessException : Exception
{
/// <summary>
/// The operation that was being performed when the exception occurred.
/// </summary>
public string? Operation { get; }
/// <summary>
/// The repository where the exception occurred.
/// </summary>
public string? Repository { get; }
/// <summary>
/// Initializes a new instance of the <see cref="DataAccessException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="operation">The operation that was being performed.</param>
/// <param name="repository">The repository where the error occurred.</param>
/// <param name="inner">The inner exception.</param>
public DataAccessException(
string message,
string? operation = null,
string? repository = null,
Exception? inner = null)
: base(message, inner)
{
Operation = operation;
Repository = repository;
}
}
@@ -0,0 +1,31 @@
namespace JdeScoping.DataAccess.Exceptions;
/// <summary>
/// Exception thrown when a database operation times out.
/// </summary>
public class DataAccessTimeoutException : DataAccessException
{
/// <summary>
/// The configured timeout value in seconds.
/// </summary>
public int TimeoutSeconds { get; }
/// <summary>
/// Initializes a new instance of the <see cref="DataAccessTimeoutException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="timeoutSeconds">The configured timeout value in seconds.</param>
/// <param name="operation">The operation that was being performed.</param>
/// <param name="repository">The repository where the error occurred.</param>
/// <param name="inner">The inner exception.</param>
public DataAccessTimeoutException(
string message,
int timeoutSeconds,
string? operation = null,
string? repository = null,
Exception? inner = null)
: base(message, operation, repository, inner)
{
TimeoutSeconds = timeoutSeconds;
}
}
@@ -0,0 +1,31 @@
namespace JdeScoping.DataAccess.Exceptions;
/// <summary>
/// Exception thrown when a database query fails.
/// </summary>
public class QueryException : DataAccessException
{
/// <summary>
/// The name of the query that failed.
/// </summary>
public string? QueryName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="QueryException"/> class.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="queryName">The name of the query that failed.</param>
/// <param name="operation">The operation that was being performed.</param>
/// <param name="repository">The repository where the error occurred.</param>
/// <param name="inner">The inner exception.</param>
public QueryException(
string message,
string? queryName,
string? operation = null,
string? repository = null,
Exception? inner = null)
: base(message, operation, repository, inner)
{
QueryName = queryName;
}
}
@@ -0,0 +1,156 @@
using System.Data;
using Dapper;
using JdeScoping.DataAccess.Models;
namespace JdeScoping.DataAccess.Extensions;
/// <summary>
/// Extension methods for SearchModel including table-valued parameters and query helpers.
/// </summary>
public static class TableValuedParameterExtensions
{
/// <summary>
/// Checks if work order step data should be searched for the given search model.
/// Steps are searched when time-based or resource-based filters are applied.
/// </summary>
/// <param name="model">Search model to evaluate.</param>
/// <returns>True if work order step data should be searched.</returns>
public static bool ShouldSearchSteps(this SearchModel model)
{
return model.MinimumDt.HasValue
|| model.MaximumDt.HasValue
|| model.ProfitCenterFilterEnabled
|| model.WorkCenterFilterEnabled
|| model.OperatorFilterEnabled;
}
/// <summary>
/// Creates a table-valued parameter for work order filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with WorkOrderNumber column.</returns>
public static SqlMapper.ICustomQueryParameter CreateWorkOrderFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("WorkOrderNumber", typeof(long));
foreach (var entry in model.WorkOrderFilter)
{
dataTable.Rows.Add(entry.WorkOrderNumber);
}
return dataTable.AsTableValuedParameter("dbo.WorkOrderFilterParameter");
}
/// <summary>
/// Creates a table-valued parameter for item number filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with ItemNumber column.</returns>
public static SqlMapper.ICustomQueryParameter CreateItemNumberFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("ItemNumber", typeof(string));
foreach (var entry in model.ItemNumberFilter)
{
dataTable.Rows.Add(entry.ItemNumber);
}
return dataTable.AsTableValuedParameter("dbo.ItemNumberFilterParameter");
}
/// <summary>
/// Creates a table-valued parameter for profit center filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with Code column.</returns>
public static SqlMapper.ICustomQueryParameter CreateProfitCenterFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("Code", typeof(string));
foreach (var entry in model.ProfitCenterFilter)
{
dataTable.Rows.Add(entry.Code);
}
return dataTable.AsTableValuedParameter("dbo.ProfitCenterFilterParameter");
}
/// <summary>
/// Creates a table-valued parameter for work center filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with Code column.</returns>
public static SqlMapper.ICustomQueryParameter CreateWorkCenterFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("Code", typeof(string));
foreach (var entry in model.WorkCenterFilter)
{
dataTable.Rows.Add(entry.Code);
}
return dataTable.AsTableValuedParameter("dbo.WorkCenterFilterParameter");
}
/// <summary>
/// Creates a table-valued parameter for operator filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with UserName column.</returns>
public static SqlMapper.ICustomQueryParameter CreateOperatorFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("UserName", typeof(string));
foreach (var entry in model.OperatorFilter)
{
dataTable.Rows.Add(entry.UserId);
}
return dataTable.AsTableValuedParameter("dbo.OperatorFilterParameter");
}
/// <summary>
/// Creates a table-valued parameter for component lot filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with ComponentLotNumber and ItemNumber columns.</returns>
public static SqlMapper.ICustomQueryParameter CreateComponentLotFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("ComponentLotNumber", typeof(string));
dataTable.Columns.Add("ItemNumber", typeof(string));
foreach (var entry in model.ComponentLotFilter)
{
dataTable.Rows.Add(entry.LotNumber, entry.ItemNumber);
}
return dataTable.AsTableValuedParameter("dbo.ComponentLotFilterParameter");
}
/// <summary>
/// Creates a table-valued parameter for item/operation/MIS filtering.
/// </summary>
/// <param name="model">The search model.</param>
/// <returns>A Dapper table-valued parameter with ItemNumber, OperationNumber, MisNumber, and MisRevision columns.</returns>
public static SqlMapper.ICustomQueryParameter CreateItemOperationMisFilterParameter(this SearchModel model)
{
var dataTable = new DataTable();
dataTable.Columns.Add("ItemNumber", typeof(string));
dataTable.Columns.Add("OperationNumber", typeof(string));
dataTable.Columns.Add("MisNumber", typeof(string));
dataTable.Columns.Add("MisRevision", typeof(string));
foreach (var entry in model.ItemOperationMisFilter)
{
dataTable.Rows.Add(entry.ItemNumber, entry.OperationNumber, entry.MisNumber, entry.MisRevision);
}
return dataTable.AsTableValuedParameter("dbo.ItemOperationMisFilterParameter");
}
}
@@ -0,0 +1,98 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for component lot filtering.
/// Generates WorkOrderComponent/LotUsage joins and sets CARDEX flag on #Temp_WO.
/// </summary>
public sealed class ComponentLotFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 30;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.ComponentLotFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var setupSql = new List<string>();
var parameters = new Dictionary<string, object>
{
["p_ComponentLotFilter"] = model.CreateComponentLotFilterParameter()
};
// Add downstream product for manually specified component lots
const string componentLotMergeSql = """
--Add downstream product for manually specified component lots
WITH CLN_CTE AS(
SELECT DISTINCT l.LotNumber,
l.ShortItemNumber,
l.BranchCode
FROM @p_ComponentLotFilter AS pclf INNER JOIN
dbo.Lot AS l ON (LTRIM(RTRIM(pclf.ComponentLotNumber)) = l.LotNumber AND LTRIM(RTRIM(pclf.ItemNumber)) = l.ItemNumber)
),
CLN_WO AS(
SELECT wo.WorkOrderNumber,
wo.BranchCode,
wo.LotNumber,
wo.ShortItemNumber
FROM CLN_CTE cln INNER JOIN
dbo.WorkOrderComponent AS woc ON (cln.LotNumber = woc.LotNumber AND cln.ShortItemNumber = woc.ShortItemNumber AND cln.BranchCode = woc.BranchCode) INNER JOIN
dbo.WorkOrder AS wo ON (woc.WorkOrderNumber = wo.WorkOrderNumber)
UNION ALL
SELECT wo.WorkOrderNumber,
wo.BranchCode,
wo.LotNumber,
wo.ShortItemNumber
FROM CLN_CTE cln INNER JOIN
dbo.LotUsage AS lu ON(cln.LotNumber = lu.LotNumber AND cln.ShortItemNumber = lu.ShortItemNumber AND cln.BranchCode = lu.BranchCode) INNER JOIN
dbo.WorkOrder AS wo ON(lu.WorkOrderNumber = wo.WorkOrderNumber)
),
CLN_FILTERED_WO AS(
SELECT DISTINCT cln.WorkOrderNumber,
cln.BranchCode,
cln.LotNumber,
cln.ShortItemNumber
FROM CLN_WO cln
)
MERGE INTO #Temp_WO AS TARGET
USING CLN_FILTERED_WO AS SOURCE
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber AND TARGET.BranchCode = SOURCE.BranchCode)
WHEN MATCHED THEN
UPDATE SET TARGET.CARDEX = 1
WHEN NOT MATCHED BY TARGET THEN
INSERT (WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, CARDEX)
VALUES (SOURCE.WorkOrderNumber, COALESCE(SOURCE.LotNumber, CAST(SOURCE.WorkOrderNumber AS VARCHAR(8))), SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
""";
setupSql.Add(componentLotMergeSql);
// Add any work orders split from flagged work orders
const string splitOrdersSql = """
--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);
""";
setupSql.Add(splitOrdersSql);
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,42 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Base class for filter handlers providing common functionality.
/// </summary>
public abstract class FilterHandlerBase : IFilterHandler
{
/// <inheritdoc />
public abstract int Priority { get; }
/// <inheritdoc />
public abstract bool IsEnabled(SearchModel model);
/// <inheritdoc />
public abstract FilterResult Apply(SearchModel model, SqlServerCompiler compiler);
/// <summary>
/// Creates an empty filter result with no SQL or parameters.
/// </summary>
protected static FilterResult EmptyResult()
=> new FilterResult([], new Dictionary<string, object>());
/// <summary>
/// Creates a filter result with setup SQL and optional parameters.
/// </summary>
protected static FilterResult WithSetupSql(
IReadOnlyList<string> setupSql,
IDictionary<string, object>? parameters = null)
=> new FilterResult(setupSql, parameters ?? new Dictionary<string, object>());
/// <summary>
/// Creates a filter result with a single setup SQL statement.
/// </summary>
protected static FilterResult WithSetupSql(
string sql,
IDictionary<string, object>? parameters = null)
=> new FilterResult([sql], parameters ?? new Dictionary<string, object>());
}
@@ -0,0 +1,46 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for item number filtering.
/// Generates #P_ItemNumbers temp table.
/// </summary>
public sealed class ItemNumberFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 20;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.ItemNumberFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var parameters = new Dictionary<string, object>
{
["p_ItemNumberFilter"] = model.CreateItemNumberFilterParameter()
};
const string setupSql = """
--Setup item number filter temp table
IF OBJECT_ID('tempdb.dbo.#P_ItemNumbers', 'U') IS NOT NULL
BEGIN
DROP TABLE #P_ItemNumbers;
END
CREATE TABLE #P_ItemNumbers (
ItemNumber VARCHAR(25) NOT NULL,
PRIMARY KEY CLUSTERED(ItemNumber)
);
INSERT INTO #P_ItemNumbers(ItemNumber)
SELECT DISTINCT LTRIM(RTRIM(pinf.ItemNumber))
FROM @p_ItemNumberFilter AS pinf
WHERE LTRIM(RTRIM(pinf.ItemNumber)) IS NOT NULL;
""";
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,55 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for item/operation/MIS filtering.
/// Generates #P_PartOperations temp table.
/// </summary>
public sealed class ItemOperationMisFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 70;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.ItemOperationMisFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var parameters = new Dictionary<string, object>
{
["p_ItemOperationMisFilter"] = model.CreateItemOperationMisFilterParameter()
};
const string setupSql = """
--Setup item/operation/mis filter temp table
IF OBJECT_ID('tempdb.dbo.#P_PartOperations', 'U') IS NOT NULL
BEGIN
DROP TABLE #P_PartOperations;
END
CREATE TABLE #P_PartOperations(
ItemNumber VARCHAR(32) NOT NULL,
OperationNumber VARCHAR(32) NOT NULL,
MisNumber VARCHAR(32) NOT NULL,
MisRevision VARCHAR(32) NOT NULL,
PRIMARY KEY CLUSTERED(ItemNumber, OperationNumber, MisNumber, MisRevision)
);
INSERT INTO #P_PartOperations(ItemNumber, OperationNumber, MisNumber, MisRevision)
SELECT DISTINCT LTRIM(RTRIM(piomf.ItemNumber)),
LTRIM(RTRIM(piomf.OperationNumber)),
LTRIM(RTRIM(piomf.MisNumber)),
LTRIM(RTRIM(piomf.MisRevision))
FROM @p_ItemOperationMisFilter AS piomf
WHERE LTRIM(RTRIM(piomf.ItemNumber)) IS NOT NULL AND
LTRIM(RTRIM(piomf.OperationNumber)) IS NOT NULL AND
LTRIM(RTRIM(piomf.MisNumber)) IS NOT NULL AND
LTRIM(RTRIM(piomf.MisRevision)) IS NOT NULL;
""";
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,55 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for operator (user) filtering.
/// Generates #P_OperatorIDs temp table with JdeUser lookup.
/// </summary>
public sealed class OperatorFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 60;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.OperatorFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var parameters = new Dictionary<string, object>
{
["p_OperatorFilter"] = model.CreateOperatorFilterParameter()
};
const string setupSql = """
--Setup operator filter temp table
IF OBJECT_ID('tempdb.dbo.#P_OperatorIDs', 'U') IS NOT NULL
BEGIN
DROP TABLE #P_OperatorIDs;
END
CREATE TABLE #P_OperatorIDs(
AddressNumber BIGINT NOT NULL,
UserID VARCHAR(10) NULL,
PRIMARY KEY CLUSTERED(AddressNumber)
);
WITH O_CTE AS(
SELECT ju.AddressNumber,
ju.UserID,
ROW_NUMBER() OVER(PARTITION BY ju.AddressNumber ORDER BY ju.UserID DESC) RN
FROM @p_OperatorFilter AS pof INNER JOIN
dbo.JdeUser AS ju ON (LTRIM(RTRIM(pof.UserName)) = ju.UserID)
)
INSERT INTO #P_OperatorIDs(AddressNumber, UserID)
SELECT o.AddressNumber,
o.UserID
FROM O_CTE o
WHERE o.RN = 1;
""";
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,62 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for profit center filtering.
/// Generates #P_WorkCenters temp table via OrgHierarchy join.
/// Note: This handler creates the shared #P_WorkCenters table that WorkCenterFilterHandler also uses.
/// </summary>
public sealed class ProfitCenterFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 40;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.ProfitCenterFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var setupSql = new List<string>();
var parameters = new Dictionary<string, object>
{
["p_ProfitCenterFilter"] = model.CreateProfitCenterFilterParameter()
};
// Create the work centers temp table if it doesn't exist
// (It may already exist if WorkCenterFilterHandler ran first, but that has lower priority)
const string createTableSql = """
--Setup profit center / work center filter temp table
IF OBJECT_ID('tempdb.dbo.#P_WorkCenters', 'U') IS NOT NULL
BEGIN
DROP TABLE #P_WorkCenters;
END
CREATE TABLE #P_WorkCenters (
Code VARCHAR(12) NOT NULL,
PRIMARY KEY CLUSTERED(Code)
);
""";
setupSql.Add(createTableSql);
// Insert work centers from profit center lookup via OrgHierarchy
const string mergeSql = """
WITH WCF_CTE AS(
SELECT LTRIM(RTRIM(oh.WorkCenterCode)) AS Code
FROM @p_ProfitCenterFilter AS ppcf INNER JOIN
dbo.OrgHierarchy AS oh ON (LTRIM(RTRIM(ppcf.Code)) = oh.ProfitCenterCode)
)
MERGE INTO #P_WorkCenters AS TARGET
USING WCF_CTE AS SOURCE
ON (TARGET.Code = SOURCE.Code)
WHEN NOT MATCHED BY TARGET THEN
INSERT(Code)
VALUES(SOURCE.Code);
""";
setupSql.Add(mergeSql);
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,38 @@
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for timespan (date range) filtering.
/// Provides parameters for @p_MinimumDT and @p_MaximumDT used in WHERE clauses.
/// Note: This handler doesn't generate setup SQL - it only provides parameters
/// that are used by the query builder's WHERE clause generation.
/// </summary>
public sealed class TimespanFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 80;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.TimespanFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var parameters = new Dictionary<string, object>();
if (model.MinimumDt.HasValue)
{
parameters["p_MinimumDT"] = model.MinimumDt.Value;
}
if (model.MaximumDt.HasValue)
{
parameters["p_MaximumDT"] = model.MaximumDt.Value;
}
// No setup SQL - timespan parameters are used in query WHERE clauses
return WithSetupSql([], parameters);
}
}
@@ -0,0 +1,64 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for work center filtering.
/// Generates MERGE into #P_WorkCenters temp table.
/// Note: This handler shares the #P_WorkCenters table with ProfitCenterFilterHandler.
/// </summary>
public sealed class WorkCenterFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 50;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.WorkCenterFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var setupSql = new List<string>();
var parameters = new Dictionary<string, object>
{
["p_WorkCenterFilter"] = model.CreateWorkCenterFilterParameter()
};
// If profit center filter is not enabled, we need to create the table
if (!model.ProfitCenterFilterEnabled)
{
const string createTableSql = """
--Setup work center filter temp table
IF OBJECT_ID('tempdb.dbo.#P_WorkCenters', 'U') IS NOT NULL
BEGIN
DROP TABLE #P_WorkCenters;
END
CREATE TABLE #P_WorkCenters (
Code VARCHAR(12) NOT NULL,
PRIMARY KEY CLUSTERED(Code)
);
""";
setupSql.Add(createTableSql);
}
// Merge work center codes into the temp table
const string mergeSql = """
WITH WCF_CTE AS(
SELECT DISTINCT pwcf.Code
FROM @p_WorkCenterFilter AS pwcf
WHERE LTRIM(RTRIM(pwcf.Code)) IS NOT NULL
)
MERGE INTO #P_WorkCenters AS TARGET
USING WCF_CTE AS SOURCE
ON (TARGET.Code = SOURCE.Code)
WHEN NOT MATCHED BY TARGET THEN
INSERT(Code)
VALUES(SOURCE.Code);
""";
setupSql.Add(mergeSql);
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,75 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.FilterHandlers;
/// <summary>
/// Filter handler for work order number filtering.
/// Generates MERGE into #Temp_WO with ManuallySpecified flag and split order detection.
/// </summary>
public sealed class WorkOrderFilterHandler : FilterHandlerBase
{
/// <inheritdoc />
public override int Priority => 10;
/// <inheritdoc />
public override bool IsEnabled(SearchModel model) => model.WorkOrderFilterEnabled;
/// <inheritdoc />
public override FilterResult Apply(SearchModel model, SqlServerCompiler compiler)
{
var setupSql = new List<string>();
var parameters = new Dictionary<string, object>
{
["p_WorkOrderFilter"] = model.CreateWorkOrderFilterParameter()
};
// Add manually specified work order numbers to flagged list
const string mergeWorkOrdersSql = """
--Add manually specified work order numbers to flagged list
WITH WOP_CTE AS(
SELECT DISTINCT wo.WorkOrderNumber,
wo.LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder AS wo INNER JOIN
@p_WorkOrderFilter AS pwof ON (wo.WorkOrderNumber = pwof.WorkOrderNumber)
)
MERGE #Temp_WO AS TARGET
USING WOP_CTE AS SOURCE
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber)
WHEN MATCHED THEN
UPDATE SET TARGET.ManuallySpecified = 1
WHEN NOT MATCHED BY TARGET THEN
INSERT(WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, ManuallySpecified)
VALUES(SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
""";
setupSql.Add(mergeWorkOrdersSql);
// Add any work orders split from flagged work orders
const string splitOrdersSql = """
--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);
""";
setupSql.Add(splitOrdersSql);
return WithSetupSql(setupSql, parameters);
}
}
@@ -0,0 +1,19 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Quality;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Repository for accessing CMS Oracle database.
/// </summary>
public interface ICmsRepository
{
/// <summary>
/// Gets Manufacturing Information System (MIS) data from CMS database.
/// Uses MisDataTimeoutSeconds timeout due to complex 10-table JOIN.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming MIS data records.</returns>
IAsyncEnumerable<MisData> GetMisDataAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
}
@@ -0,0 +1,38 @@
using Microsoft.Data.SqlClient;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Factory for creating database connections to all data sources.
/// </summary>
public interface IDbConnectionFactory
{
/// <summary>
/// Creates and opens a connection to the LotFinderDB SQL Server cache database.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>An open SQL Server connection. Caller is responsible for disposal.</returns>
Task<SqlConnection> CreateLotFinderConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Creates and opens a connection to the JDE Oracle database (production schema).
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>An open Oracle connection. Caller is responsible for disposal.</returns>
Task<OracleConnection> CreateJdeConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Creates and opens a connection to the JDE Stage Oracle database.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>An open Oracle connection. Caller is responsible for disposal.</returns>
Task<OracleConnection> CreateJdeStageConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Creates and opens a connection to the CMS Oracle database.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>An open Oracle connection. Caller is responsible for disposal.</returns>
Task<OracleConnection> CreateCmsConnectionAsync(CancellationToken ct = default);
}
@@ -0,0 +1,30 @@
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Interface for filter handlers that build SQL query fragments.
/// </summary>
public interface IFilterHandler
{
/// <summary>
/// Determines if this filter is active for the given search model.
/// </summary>
/// <param name="model">The search model to check.</param>
/// <returns>True if the filter is enabled and should be applied.</returns>
bool IsEnabled(SearchModel model);
/// <summary>
/// Applies the filter, returning setup SQL and parameters.
/// </summary>
/// <param name="model">The search model containing filter criteria.</param>
/// <param name="compiler">The SqlKata compiler for SQL generation.</param>
/// <returns>Filter result containing setup SQL and parameters.</returns>
FilterResult Apply(SearchModel model, SqlServerCompiler compiler);
/// <summary>
/// Priority for handler execution order (lower = earlier).
/// </summary>
int Priority { get; }
}
@@ -0,0 +1,43 @@
using JdeScoping.Core.Models.Inventory;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Inventory (lots) operations for JDE Oracle repository.
/// </summary>
public partial interface IJdeRepository
{
/// <summary>
/// Gets lot master data from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming lots.</returns>
IAsyncEnumerable<Lot> GetLotsAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets lot usage (cardex) transactions from production schema, optionally filtered by last update.
/// Uses special LotUsageTimeoutSeconds timeout due to large dataset.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming lot usages.</returns>
IAsyncEnumerable<LotUsage> GetLotUsagesAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets lot usage transactions from archive schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming archived lot usages.</returns>
IAsyncEnumerable<LotUsage> GetLotUsagesArchiveAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets lot location tracking from JDE Stage view.
/// Uses JDE Stage connection.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming lot locations.</returns>
IAsyncEnumerable<LotLocation> GetLotLocationsAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
}
@@ -0,0 +1,86 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Lookup;
using JdeScoping.Core.Models.Organization;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Reference data operations for JDE Oracle repository.
/// </summary>
public partial interface IJdeRepository
{
/// <summary>
/// Gets item master data from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming items.</returns>
IAsyncEnumerable<Item> GetItemsAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets user/operator data from production schema.
/// Note: Incremental filtering not supported for users (full sync always).
/// </summary>
/// <param name="lastUpdateDt">Ignored (full sync always).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming users.</returns>
IAsyncEnumerable<JdeUser> GetUsersAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets branch business units from production schema (type code 'BP').
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming branches.</returns>
IAsyncEnumerable<Branch> GetBranchesAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets profit center business units from production schema (type code 'I3').
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming profit centers.</returns>
IAsyncEnumerable<ProfitCenter> GetProfitCentersAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work center business units from production schema (type code 'WC').
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming work centers.</returns>
IAsyncEnumerable<WorkCenter> GetWorkCentersAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets status codes from JDE Stage view.
/// Uses JDE Stage connection.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming status codes.</returns>
IAsyncEnumerable<StatusCode> GetStatusCodesAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets function codes from production schema.
/// Note: Does not support incremental filtering (full sync always).
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming function codes.</returns>
IAsyncEnumerable<FunctionCode> GetFunctionCodesAsync(CancellationToken ct = default);
/// <summary>
/// Gets organization hierarchy (work center to profit center mapping) from production schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming org hierarchy records.</returns>
IAsyncEnumerable<OrgHierarchy> GetOrgHierarchyAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets item routing master data from production schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming route masters.</returns>
IAsyncEnumerable<RouteMaster> GetRouteMastersAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
}
@@ -0,0 +1,81 @@
using JdeScoping.Core.Models.WorkOrders;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Work order operations for JDE Oracle repository.
/// </summary>
public partial interface IJdeRepository
{
/// <summary>
/// Gets work orders from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming work orders.</returns>
IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work orders from archive schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming archived work orders.</returns>
IAsyncEnumerable<WorkOrder> GetWorkOrdersArchiveAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order steps from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming work order steps.</returns>
IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order steps from archive schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming archived work order steps.</returns>
IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsArchiveAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order time transactions from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming work order times.</returns>
IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order time transactions from archive schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming archived work order times.</returns>
IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesArchiveAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order routing transactions from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming work order routings.</returns>
IAsyncEnumerable<WorkOrderRouting> GetWorkOrderRoutingsAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order component usage from production schema, optionally filtered by last update.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming work order components.</returns>
IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
/// <summary>
/// Gets work order component usage from archive schema.
/// </summary>
/// <param name="lastUpdateDt">Optional cutoff for incremental sync.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Streaming archived work order components.</returns>
IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsArchiveAsync(DateTime? lastUpdateDt = null, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Repository for accessing JDE Oracle database.
/// All methods return IAsyncEnumerable for memory-efficient streaming.
/// </summary>
public partial interface IJdeRepository
{
}
@@ -0,0 +1,30 @@
using JdeScoping.DataAccess.Models;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Interface for building search queries using SqlKata.
/// </summary>
public interface ISearchQueryBuilder
{
/// <summary>
/// Builds the main search query for flagging and retrieving work orders.
/// </summary>
/// <param name="model">The search model containing filter criteria.</param>
/// <returns>The compiled query result with SQL, parameters, and setup statements.</returns>
SearchQueryResult BuildSearchQuery(SearchModel model);
/// <summary>
/// Builds the MIS data extraction query when ExtractMisData is enabled.
/// </summary>
/// <param name="model">The search model containing filter criteria.</param>
/// <returns>The compiled query result for MIS extraction.</returns>
SearchQueryResult BuildMisQuery(SearchModel model);
/// <summary>
/// Builds the MIS non-match query for work orders without MIS records.
/// </summary>
/// <param name="model">The search model containing filter criteria.</param>
/// <returns>The compiled query result for MIS non-match extraction.</returns>
SearchQueryResult BuildMisNonMatchQuery(SearchModel model);
}
@@ -0,0 +1,21 @@
using Microsoft.Data.SqlClient;
namespace JdeScoping.DataAccess.Interfaces;
/// <summary>
/// Service for traversing downstream work orders.
/// </summary>
public interface IWorkOrderTraversalService
{
/// <summary>
/// Traverses downstream work orders via stored procedure or iterative SQL.
/// Called after initial filtering to find related work orders.
/// </summary>
/// <param name="connection">The SQL connection with the #Temp_WO temp table already populated.</param>
/// <param name="maxIterations">Maximum number of traversal iterations.</param>
/// <param name="ct">Cancellation token.</param>
Task TraverseDownstreamAsync(
SqlConnection connection,
int maxIterations = 20,
CancellationToken ct = default);
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="SqlKata" Version="3.2.3" />
<PackageReference Include="SqlKata.Execution" Version="3.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,22 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Component lot search filter entry.
/// </summary>
[OutputTable(TabName = "Component Lot Filter", ShowHeader = true, TableName = "Component_Lot_Filter")]
public sealed record ComponentLotFilterEntry
{
/// <summary>
/// Component lot number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Lot Number")]
public string LotNumber { get; init; } = string.Empty;
/// <summary>
/// Component lot item number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
}
@@ -0,0 +1,22 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Item number search filter entry.
/// </summary>
[OutputTable(TabName = "Item Number Filter", ShowHeader = true, TableName = "Item_Number_Filter")]
public sealed record ItemNumberFilterEntry
{
/// <summary>
/// Item number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
/// <summary>
/// Item description.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Item Description")]
public string ItemDescription { get; init; } = string.Empty;
}
@@ -0,0 +1,34 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Item/operation/MIS search filter entry.
/// </summary>
[OutputTable(TabName = "Item/Operation/MIS Filter", ShowHeader = true, TableName = "Item_Operation_MIS_Filter")]
public sealed record ItemOperationMisFilterEntry
{
/// <summary>
/// Part's item number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
/// <summary>
/// Operation's job step number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Operation Number")]
public string OperationNumber { get; init; } = string.Empty;
/// <summary>
/// MIS number.
/// </summary>
[OutputColumn(Order = 30, HeaderText = "MIS Number")]
public string MisNumber { get; init; } = string.Empty;
/// <summary>
/// MIS revision.
/// </summary>
[OutputColumn(Order = 40, HeaderText = "MIS Revision")]
public string MisRevision { get; init; } = string.Empty;
}
@@ -0,0 +1,27 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Operator search filter entry.
/// </summary>
[OutputTable(TabName = "Operator Filter", ShowHeader = true, TableName = "Operator_Filter")]
public sealed record OperatorFilterEntry
{
/// <summary>
/// Operator unique JDE address number.
/// </summary>
public long AddressNumber { get; init; }
/// <summary>
/// Operator login user ID.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Username")]
public string UserId { get; init; } = string.Empty;
/// <summary>
/// Operator full name (FIRST + LAST).
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Name")]
public string FullName { get; init; } = string.Empty;
}
@@ -0,0 +1,22 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Profit center search filter entry.
/// </summary>
[OutputTable(TabName = "Profit Center Filter", ShowHeader = true, TableName = "Profit_Center_Filter")]
public sealed record ProfitCenterFilterEntry
{
/// <summary>
/// Profit center code.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Profit Center")]
public string Code { get; init; } = string.Empty;
/// <summary>
/// Profit center description.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Description")]
public string Description { get; init; } = string.Empty;
}
@@ -0,0 +1,22 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Work center search filter entry.
/// </summary>
[OutputTable(TabName = "Work Center Filter", ShowHeader = true, TableName = "Work_Center_Filter")]
public sealed record WorkCenterFilterEntry
{
/// <summary>
/// Work center code.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Work Center")]
public string Code { get; init; } = string.Empty;
/// <summary>
/// Work center description.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Description")]
public string Description { get; init; } = string.Empty;
}
@@ -0,0 +1,22 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.FilterEntries;
/// <summary>
/// Work order search filter entry.
/// </summary>
[OutputTable(TabName = "Work Order Filter", ShowHeader = true, TableName = "Work_Order_Filter")]
public sealed record WorkOrderFilterEntry
{
/// <summary>
/// Work order number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Work Order Number")]
public long WorkOrderNumber { get; init; }
/// <summary>
/// Work order item number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
}
@@ -0,0 +1,10 @@
namespace JdeScoping.DataAccess.Models;
/// <summary>
/// Result of applying a filter handler.
/// </summary>
/// <param name="SetupSql">List of SQL statements for filter setup (temp tables, etc.).</param>
/// <param name="Parameters">Dictionary of parameter names and values.</param>
public sealed record FilterResult(
IReadOnlyList<string> SetupSql,
IDictionary<string, object> Parameters);
@@ -0,0 +1,82 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.Results;
/// <summary>
/// MIS non-match reporting model.
/// </summary>
[OutputTable(TabName = "Investigation", TableName = "Investigation")]
public sealed record MisNonMatchSearchResult
{
/// <summary>
/// Work order job step work center code.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Work Center Code")]
public string WorkCenterCode { get; init; } = string.Empty;
/// <summary>
/// Work order unique number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Work Order Number")]
public long WorkOrderNumber { get; init; }
/// <summary>
/// Work order start date.
/// </summary>
[OutputColumn(Order = 30, HeaderText = "Work Order Start Date", Format = OutputColumnAttribute.DateFormat)]
public DateTime WorkOrderStartDate { get; init; }
/// <summary>
/// Work order job step number.
/// </summary>
[OutputColumn(Order = 40, HeaderText = "Job Step Number")]
public decimal JobStepNumber { get; init; }
/// <summary>
/// Work order job step description.
/// </summary>
[OutputColumn(Order = 50, HeaderText = "Function Operation Description")]
public string JobStepDescription { get; init; } = string.Empty;
/// <summary>
/// Work order job step completion date.
/// </summary>
[OutputColumn(Order = 60, HeaderText = "Job Step End Date", Format = OutputColumnAttribute.DateFormat)]
public DateTime? JobStepEndDate { get; init; }
/// <summary>
/// Work order job step function code.
/// </summary>
[OutputColumn(Order = 70, HeaderText = "Function Code")]
public string FunctionCode { get; init; } = string.Empty;
/// <summary>
/// Whether the job step was added (not in original router).
/// </summary>
[OutputColumn(Order = 75, HeaderText = "Was Job Step Added?")]
public bool WasJobStepAdded { get; init; }
/// <summary>
/// Matched work order job step number (match to original router by work order number, work center code, and function code).
/// </summary>
[OutputColumn(Order = 76, HeaderText = "Matched Job Step Number")]
public decimal? MatchedJobStepNumber { get; init; }
/// <summary>
/// Work order item number.
/// </summary>
[OutputColumn(Order = 80, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
/// <summary>
/// Work order item description.
/// </summary>
[OutputColumn(Order = 90, HeaderText = "Item Description")]
public string ItemDescription { get; init; } = string.Empty;
/// <summary>
/// Work order router type.
/// </summary>
[OutputColumn(Order = 100, HeaderText = "Routing Type")]
public string RoutingType { get; init; } = string.Empty;
}
@@ -0,0 +1,124 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.Results;
/// <summary>
/// MIS data reporting model.
/// </summary>
[OutputTable(TabName = "MIS Info", TableName = "MIS_Info")]
public sealed record MisSearchResult
{
/// <summary>
/// Item unique number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
/// <summary>
/// Item description.
/// </summary>
[OutputColumn(Order = 50, HeaderText = "Item Description")]
public string ItemDescription { get; init; } = string.Empty;
/// <summary>
/// Operation job step number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "MIS Job Step Sequence Number")]
public string SequenceNumber { get; init; } = string.Empty;
/// <summary>
/// MIS unique number.
/// </summary>
[OutputColumn(Order = 30, HeaderText = "MIS Number")]
public string MisNumber { get; init; } = string.Empty;
/// <summary>
/// MIS revision ID.
/// </summary>
[OutputColumn(Order = 40, HeaderText = "MIS Revision")]
public string RevId { get; init; } = string.Empty;
/// <summary>
/// MIS release status.
/// </summary>
[OutputColumn(Order = 60, HeaderText = "MIS Release Status")]
public string Status { get; init; } = string.Empty;
/// <summary>
/// MIS release date.
/// </summary>
[OutputColumn(Order = 70, HeaderText = "MIS Release Date", Format = OutputColumnAttribute.TimestampFormat)]
public DateTime? ReleaseDate { get; init; }
/// <summary>
/// Branch unique code.
/// </summary>
[OutputColumn(Order = 80, HeaderText = "Branch Code")]
public string BranchCode { get; init; } = string.Empty;
/// <summary>
/// Job step number.
/// </summary>
[OutputColumn(Order = 90, HeaderText = "Job Step Sequence Number")]
public decimal JobStepSequenceNumber { get; init; }
/// <summary>
/// Job step number for matched F3112Z1 / F3111 record.
/// </summary>
[OutputColumn(Order = 100, HeaderText = "Matched Sequence Number")]
public decimal? MatchedSequenceNumber { get; init; }
/// <summary>
/// Whether or not the job step was matched to F3112Z1 record.
/// </summary>
[OutputColumn(Order = 110, HeaderText = "Matched to F3112Z1?")]
public bool RoutingMatch { get; init; }
/// <summary>
/// Whether or not the job step was matched to F3111 record.
/// </summary>
[OutputColumn(Order = 120, HeaderText = "Matched to F3003?")]
public bool MasterMatch { get; init; }
/// <summary>
/// Job step function description.
/// </summary>
[OutputColumn(Order = 130, HeaderText = "Function Operation Description")]
public string FunctionOperationDescription { get; init; } = string.Empty;
/// <summary>
/// Characteristic number.
/// </summary>
[OutputColumn(Order = 140, HeaderText = "Char Number")]
public string CharNumber { get; init; } = string.Empty;
/// <summary>
/// Test description.
/// </summary>
[OutputColumn(Order = 150, HeaderText = "Test Description", AutoWidth = false, Width = OutputColumnAttribute.WrappedColumnWidth, WrapText = true)]
public string TestDescription { get; init; } = string.Empty;
/// <summary>
/// Type of sampling.
/// </summary>
[OutputColumn(Order = 160, HeaderText = "Sampling Type")]
public string SamplingType { get; init; } = string.Empty;
/// <summary>
/// Sampling selection value.
/// </summary>
[OutputColumn(Order = 170, HeaderText = "Sampling Value")]
public string SamplingValue { get; init; } = string.Empty;
/// <summary>
/// Tools and gauges for MIS.
/// </summary>
[OutputColumn(Order = 180, HeaderText = "Tools & Gauges", AutoWidth = false, Width = OutputColumnAttribute.WrappedColumnWidth, WrapText = true)]
public string ToolsGauges { get; init; } = string.Empty;
/// <summary>
/// Instructions for MIS.
/// </summary>
[OutputColumn(Order = 190, HeaderText = "Work Instructions", AutoWidth = false, Width = OutputColumnAttribute.WrappedColumnWidth, WrapText = true)]
public string WorkInstructions { get; init; } = string.Empty;
}
@@ -0,0 +1,179 @@
using JdeScoping.DataAccess.Attributes;
namespace JdeScoping.DataAccess.Models.Results;
/// <summary>
/// JDE search result reporting model.
/// </summary>
[OutputTable(TabName = "Search Results", TableName = "Search_Results")]
public sealed record SearchResult
{
/// <summary>
/// Order unique number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Work Order Number")]
public long WorkOrderNumber { get; init; }
/// <summary>
/// Order branch code.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Work Order Branch Code")]
public string WorkOrderBranchCode { get; init; } = string.Empty;
/// <summary>
/// Order lot number.
/// </summary>
[OutputColumn(Order = 30, HeaderText = "Lot Number")]
public string LotNumber { get; init; } = string.Empty;
/// <summary>
/// Order item number.
/// </summary>
[OutputColumn(Order = 40, HeaderText = "Item Number")]
public string ItemNumber { get; init; } = string.Empty;
/// <summary>
/// Item master planning family.
/// </summary>
[OutputColumn(Order = 50, HeaderText = "Planning Family")]
public string PlanningFamily { get; init; } = string.Empty;
/// <summary>
/// Item master stocking type.
/// </summary>
[OutputColumn(Order = 55, HeaderText = "Stocking Type")]
public string StockingType { get; init; } = string.Empty;
/// <summary>
/// Order quantity.
/// </summary>
[OutputColumn(Order = 60, HeaderText = "Order Quantity")]
public decimal OrderQuantity { get; init; }
/// <summary>
/// Quantity on hold.
/// </summary>
[OutputColumn(Order = 70, HeaderText = "Held Quantity")]
public decimal HeldQuantity { get; init; }
/// <summary>
/// Quantity scrapped/cancelled.
/// </summary>
[OutputColumn(Order = 80, HeaderText = "Scrapped Quantity")]
public decimal ScrappedQuantity { get; init; }
/// <summary>
/// Quantity shipped.
/// </summary>
[OutputColumn(Order = 90, HeaderText = "Shipped Quantity")]
public decimal ShippedQuantity { get; init; }
/// <summary>
/// Operation branch code.
/// </summary>
[OutputColumn(Order = 100, HeaderText = "Operation Step Branch Code")]
public string StepBranchCode { get; init; } = string.Empty;
/// <summary>
/// Operation step number.
/// </summary>
[OutputColumn(Order = 110, HeaderText = "Operation Step")]
public decimal StepNumber { get; init; }
/// <summary>
/// Operation step description.
/// </summary>
[OutputColumn(Order = 120, HeaderText = "Operation Step Description")]
public string StepDescription { get; init; } = string.Empty;
/// <summary>
/// Function operation description (long text).
/// </summary>
[OutputColumn(Order = 130, HeaderText = "Function Operation Description")]
public string FunctionOperationDescription { get; init; } = string.Empty;
/// <summary>
/// Timestamp of last update to operation step number.
/// </summary>
[OutputColumn(Order = 140, HeaderText = "Operation Step Update Timestamp", Format = OutputColumnAttribute.TimestampFormat)]
public DateTime StepUpdateDt { get; init; }
/// <summary>
/// Order status code.
/// </summary>
[OutputColumn(Order = 150, HeaderText = "Status Code")]
public string StatusCode { get; init; } = string.Empty;
/// <summary>
/// Order status description.
/// </summary>
[OutputColumn(Order = 160, HeaderText = "Status Description")]
public string StatusDescription { get; init; } = string.Empty;
/// <summary>
/// Timestamp of last update to order status.
/// </summary>
[OutputColumn(Order = 170, HeaderText = "Status Update Timestamp", Format = OutputColumnAttribute.DateFormat)]
public DateTime? StatusUpdateDt { get; init; }
/// <summary>
/// Work order was included because it was manually specified.
/// </summary>
public bool ManuallySpecified { get; init; }
/// <summary>
/// Work order was included because it was split from a flagged work order.
/// </summary>
public bool SplitOrder { get; init; }
/// <summary>
/// Work order was included because it received parts from a flagged work order (CARDEX / F4111).
/// </summary>
public bool Cardex { get; init; }
/// <summary>
/// Work order was included because it received parts from a flagged work order (parts list / F3111).
/// </summary>
public bool PartsList { get; init; }
/// <summary>
/// Work order was included because it met the filter criteria.
/// </summary>
public bool Flagged { get; init; }
/// <summary>
/// Reason work order was included in results.
/// </summary>
[OutputColumn(Order = 180, HeaderText = "Inclusion Reason")]
public string InclusionReason
{
get
{
if (ManuallySpecified)
{
return "ManuallySpecified";
}
if (Flagged)
{
return "Flagged";
}
if (Cardex && PartsList)
{
return "ComponentUsage (CARDEX + Parts List)";
}
if (Cardex && !PartsList)
{
return "ComponentUsage (CARDEX)";
}
if (!Cardex && PartsList)
{
return "ComponentUsage (Parts List)";
}
if (SplitOrder)
{
return "Split order";
}
return "UNKNOWN";
}
}
}
@@ -0,0 +1,145 @@
using JdeScoping.DataAccess.Models.FilterEntries;
using JdeScoping.DataAccess.Models.Results;
namespace JdeScoping.DataAccess.Models;
/// <summary>
/// Reporting search data model.
/// </summary>
public class SearchModel
{
/// <summary>
/// PK ID of search.
/// </summary>
public int Id { get; set; }
/// <summary>
/// User name of user that created search.
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// User-friendly name for search.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Timestamp search was submitted.
/// </summary>
public DateTime? SubmitDt { get; set; }
/// <summary>
/// Timestamp search was started.
/// </summary>
public DateTime? StartDt { get; set; }
/// <summary>
/// Timestamp search was completed.
/// </summary>
public DateTime? EndDt { get; set; }
/// <summary>
/// Minimum timestamp to include.
/// </summary>
public DateTime? MinimumDt { get; set; }
/// <summary>
/// Maximum timestamp to include.
/// </summary>
public DateTime? MaximumDt { get; set; }
/// <summary>
/// Whether or not timespan filter is enabled.
/// </summary>
public bool TimespanFilterEnabled => MinimumDt.HasValue || MaximumDt.HasValue;
/// <summary>
/// Collection of work order numbers to include.
/// </summary>
public List<WorkOrderFilterEntry> WorkOrderFilter { get; set; } = [];
/// <summary>
/// Whether or not work order filter is enabled.
/// </summary>
public bool WorkOrderFilterEnabled => WorkOrderFilter is { Count: > 0 };
/// <summary>
/// Collection of item numbers to include.
/// </summary>
public List<ItemNumberFilterEntry> ItemNumberFilter { get; set; } = [];
/// <summary>
/// Whether or not item number filter is enabled.
/// </summary>
public bool ItemNumberFilterEnabled => ItemNumberFilter is { Count: > 0 };
/// <summary>
/// Collection of included profit centers.
/// </summary>
public List<ProfitCenterFilterEntry> ProfitCenterFilter { get; set; } = [];
/// <summary>
/// Whether or not profit center filter is enabled.
/// </summary>
public bool ProfitCenterFilterEnabled => ProfitCenterFilter is { Count: > 0 };
/// <summary>
/// Collection of included work centers.
/// </summary>
public List<WorkCenterFilterEntry> WorkCenterFilter { get; set; } = [];
/// <summary>
/// Whether or not work center filter is enabled.
/// </summary>
public bool WorkCenterFilterEnabled => WorkCenterFilter is { Count: > 0 };
/// <summary>
/// Collection of included operator IDs.
/// </summary>
public List<OperatorFilterEntry> OperatorFilter { get; set; } = [];
/// <summary>
/// Whether or not operator filter is enabled.
/// </summary>
public bool OperatorFilterEnabled => OperatorFilter is { Count: > 0 };
/// <summary>
/// Collection of included upper level lot numbers.
/// </summary>
public List<ComponentLotFilterEntry> ComponentLotFilter { get; set; } = [];
/// <summary>
/// Whether or not component lot filter is enabled.
/// </summary>
public bool ComponentLotFilterEnabled => ComponentLotFilter is { Count: > 0 };
/// <summary>
/// List of part/operation combinations for MIS filtering.
/// </summary>
public List<ItemOperationMisFilterEntry> ItemOperationMisFilter { get; set; } = [];
/// <summary>
/// Whether or not item/operation/mis filter is enabled.
/// </summary>
public bool ItemOperationMisFilterEnabled => ItemOperationMisFilter is { Count: > 0 };
/// <summary>
/// Whether or not to extract MIS data.
/// </summary>
public bool ExtractMisData { get; set; }
/// <summary>
/// Work order search results.
/// </summary>
public List<SearchResult> Results { get; set; } = [];
/// <summary>
/// MIS results.
/// </summary>
public List<MisSearchResult> MisResults { get; set; } = [];
/// <summary>
/// MIS no match found results.
/// </summary>
public List<MisNonMatchSearchResult> MisNonMatchResults { get; set; } = [];
}
@@ -0,0 +1,12 @@
namespace JdeScoping.DataAccess.Models;
/// <summary>
/// Result of building a search query, containing the SQL and parameters.
/// </summary>
/// <param name="Sql">The compiled SQL query string.</param>
/// <param name="Parameters">Dictionary of parameter names and values.</param>
/// <param name="TempTableSetupSql">List of SQL statements for temp table setup (executed before main query).</param>
public sealed record SearchQueryResult(
string Sql,
IDictionary<string, object> Parameters,
IReadOnlyList<string> TempTableSetupSql);
@@ -0,0 +1,45 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// SQL query constants for the CMS Oracle database.
/// </summary>
public static class CmsQueries
{
/// <summary>
/// Gets Manufacturing Information System (MIS) data from CMS database.
/// Complex 10-table JOIN through CMS schema (INFODBA).
/// </summary>
public const string SqlGetMisData = @"
SELECT DISTINCT
mis.P_PART_NUMBER AS ItemNumber,
mis.P_OPERATION_NUMBER AS SequenceNumber,
item.PITEM_ID AS MISNumber,
itemrev.PITEM_REVISION_ID AS RevID,
TRIM(mis.P_SITE) AS BranchCode,
zim_test_details.P_SEQ_NUMBER AS CharNumber,
zim_test_details.P_TEST_DESC AS TestDescription,
zim_test_details.P_SAMPL_TYPE AS SamplingType,
zim_test_details.P_SAMPL_VALUE AS SamplingValue,
zim_test_details.P_TOOLS AS ToolsGauges,
zim_test_details.P_WORK_INTR AS WorkInstructions,
Status.PNAME AS Status,
Status.PDATE_RELEASED AS ReleaseDate
FROM INFODBA.PITEM item
INNER JOIN INFODBA.PITEMREVISION itemrev ON (item.PUID = itemrev.RITEMS_TAGU)
INNER JOIN INFODBA.PRELEASE_STATUS_LIST listing ON (itemrev.PUID = listing.PUID)
INNER JOIN INFODBA.PRELEASESTATUS Status ON (listing.PVALU_0 = Status.PUID)
INNER JOIN INFODBA.PIMANRELATION imanrel ON (itemrev.PUID = imanrel.RPRIMARY_OBJECTU)
INNER JOIN INFODBA.PFORM form ON (imanrel.RSECONDARY_OBJECTU = form.PUID)
INNER JOIN INFODBA.PZIMMERMISDETAILS zim_mis ON (form.RDATA_FILEU = zim_mis.PUID)
INNER JOIN INFODBA.P_TEST_DETAILS test_details ON (zim_mis.PUID = test_details.PUID)
INNER JOIN INFODBA.P_PART_ASSOCIATION ppa ON (ppa.PUID = test_details.PUID)
INNER JOIN INFODBA.PMISDATAOBJECT mis ON (mis.PUID = ppa.PVALU_0)
INNER JOIN INFODBA.PZIMTESTDETAILS zim_test_details ON (test_details.PVALU_0 = zim_test_details.PUID)
WHERE Status.PNAME IN ('Current', 'BackLevel')";
/// <summary>
/// Gets MIS data updated since specified date from CMS database.
/// </summary>
public const string SqlGetMisDataFiltered = SqlGetMisData + @"
AND Status.PDATE_RELEASED >= :lastUpdateDT";
}
@@ -0,0 +1,113 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Inventory related SQL queries (Lots, Lot Usages, Lot Locations, Items)
/// </summary>
public static partial class JdeQueries
{
/// <summary>
/// Gets all lots from production schema.
/// </summary>
public const string SqlGetLots = @"
SELECT TRIM(lot.IOLOTN) AS LotNumber,
TRIM(lot.IOMCU) AS BranchCode,
lot.IOITM AS ShortItemNumber,
TRIM(lot.IOLITM) AS ItemNumber,
lot.IOVEND AS SupplierCode,
lot.IOLOTS AS StatusCode,
TRIM(lot.IOLOT1) AS Memo1,
TRIM(lot.IOLOT2) AS Memo2,
TRIM(lot.IOLOT3) AS Memo3,
lot.IOUPMJ AS LastUpdateDate,
lot.IOTDAY AS LastUpdateTime
FROM {ProductionSchema}.F4108 lot
WHERE TRIM(lot.IOLOTN) IS NOT NULL AND
TRIM(lot.IOMCU) IS NOT NULL";
/// <summary>
/// Gets lots updated since specified date from production schema.
/// </summary>
public const string SqlGetLotsFiltered = SqlGetLots + @"
AND (lot.IOUPMJ > :dateUpdated OR
(lot.IOUPMJ = :dateUpdated AND lot.IOTDAY >= :timeUpdated))";
/// <summary>
/// Gets all lot usages (cardex) from production schema.
/// </summary>
public const string SqlGetLotUsages = @"
SELECT lu.ILUKID AS UniqueId,
lu.ILDOCO AS WorkOrderNumber,
TRIM(lu.ILLOTN) AS LotNumber,
TRIM(lu.ILMCU) AS BranchCode,
lu.ILITM AS ShortItemNumber,
lu.ILTRQT AS Quantity,
lu.ILTRDJ AS LastUpdateDate,
lu.ILTDAY AS LastUpdateTime
FROM {ProductionSchema}.F4111 lu
WHERE lu.ILDCT = 'IM' AND
TRIM(lu.ILLOTN) IS NOT NULL";
/// <summary>
/// Gets lot usages updated since specified date from production schema.
/// </summary>
public const string SqlGetLotUsagesFiltered = SqlGetLotUsages + @"
AND (lu.ILTRDJ > :dateUpdated OR
(lu.ILTRDJ = :dateUpdated AND lu.ILTDAY >= :timeUpdated))";
/// <summary>
/// Gets all lot usages from archive schema.
/// </summary>
public const string SqlGetLotUsagesArchive = @"
SELECT lu.ILUKID AS UniqueId,
lu.ILDOCO AS WorkOrderNumber,
TRIM(lu.ILLOTN) AS LotNumber,
TRIM(lu.ILMCU) AS BranchCode,
lu.ILITM AS ShortItemNumber,
lu.ILTRQT AS Quantity,
lu.ILTRDJ AS LastUpdateDate,
lu.ILTDAY AS LastUpdateTime
FROM {ArchiveSchema}.F4111 lu
WHERE lu.ILDCT = 'IM' AND
TRIM(lu.ILLOTN) IS NOT NULL";
/// <summary>
/// Gets all lot locations from production schema.
/// </summary>
public const string SqlGetLotLocations = @"
SELECT TRIM(il.LILOTN) AS LotNumber,
il.LIITM AS ShortItemNumber,
TRIM(il.LIMCU) AS BranchCode,
COALESCE(TRIM(il.LILOCN), ' ') AS Location,
il.LIUPMJ AS LastUpdateDate,
il.LITDAY AS LastUpdateTime
FROM {ProductionSchema}.F41021 il
WHERE TRIM(il.LILOTN) IS NOT NULL";
/// <summary>
/// Gets lot locations updated since specified date from production schema.
/// </summary>
public const string SqlGetLotLocationsFiltered = SqlGetLotLocations + @"
AND (il.LIUPMJ > :dateUpdated OR
(il.LIUPMJ = :dateUpdated AND il.LITDAY >= :timeUpdated))";
/// <summary>
/// Gets all items from production schema.
/// </summary>
public const string SqlGetItems = @"
SELECT pn.IMITM AS ShortItemNumber,
TRIM(pn.IMLITM) AS ItemNumber,
TRIM(pn.IMDSC1) AS Description,
TRIM(pn.IMPRP4) AS PlanningFamily,
TRIM(pn.IMSTKT) AS StockingType,
pn.IMUPMJ AS LastUpdateDate,
pn.IMTDAY AS LastUpdateTime
FROM {ProductionSchema}.F4101 pn
WHERE TRIM(pn.IMLITM) IS NOT NULL";
/// <summary>
/// Gets items updated since specified date from production schema.
/// </summary>
public const string SqlGetItemsFiltered = SqlGetItems + @"
AND (pn.IMUPMJ > :dateUpdated OR
(pn.IMUPMJ = :dateUpdated AND pn.IMTDAY >= :timeUpdated))";
}
@@ -0,0 +1,50 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Lookup related SQL queries (Status Codes, Function Codes)
/// </summary>
public static partial class JdeQueries
{
/// <summary>
/// Gets all work order status codes from production schema.
/// Status codes are stored in UDC table F0005 with SY='00' and RT='SS'.
/// </summary>
public const string SqlGetStatusCodes = @"
SELECT TRIM(sc.DRKY) AS Code,
TRIM(sc.DRDL01) AS Description,
sc.DRUPMJ AS LastUpdateDate,
sc.DRUPMT AS LastUpdateTime
FROM {ProductionSchema}.F0005 sc
WHERE TRIM(sc.DRSY) = '00' AND
sc.DRRT = 'SS' AND
TRIM(sc.DRKY) IS NOT NULL";
/// <summary>
/// Gets status codes updated since specified date from production schema.
/// </summary>
public const string SqlGetStatusCodesFiltered = SqlGetStatusCodes + @"
AND (sc.DRUPMJ > :dateUpdated OR
(sc.DRUPMJ = :dateUpdated AND sc.DRUPMT >= :timeUpdated))";
/// <summary>
/// Gets all function codes from production schema.
/// Function codes are stored in F00192 (MES codes table).
/// Uses LISTAGG to concatenate multiple descriptions for same code.
/// </summary>
public const string SqlGetFunctionCodes = @"
SELECT Code,
TRIM(LISTAGG(Description, ' ') WITHIN GROUP(ORDER BY Description) ||
CASE WHEN MAX(total_lengthb) > 4000 THEN '...' ELSE '' END) AS Description,
SYSDATE AS LastUpdateDt
FROM (
SELECT TRIM(fc.CFKY) AS Code,
TRIM(ASCIISTR(fc.CFDS80)) AS Description,
SUM(LENGTHB(TRIM(fc.CFDS80))+1) OVER(PARTITION BY TRIM(fc.CFKY) ORDER BY TRIM(fc.CFDS80)) - 1 cumul_lengthb,
SUM(LENGTHB(TRIM(fc.CFDS80))+1) OVER(PARTITION BY TRIM(fc.CFKY)) - 1 total_lengthb,
COUNT(*) OVER(PARTITION BY TRIM(fc.CFKY)) num_values
FROM {ProductionSchema}.F00192 fc
WHERE TRIM(fc.CFKY) IS NOT NULL
)
WHERE total_lengthb <= 4000 OR cumul_lengthb <= 4000 - length('...')
GROUP BY Code";
}
@@ -0,0 +1,95 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Organization related SQL queries (Users, Business Units, Org Hierarchy, Route Masters)
/// </summary>
public static partial class JdeQueries
{
/// <summary>
/// Gets all JDE users from production schema.
/// Uses CTE to get unique users with most recent address book record.
/// </summary>
public const string SqlGetUsers = @"
WITH USER_CTE AS (
SELECT ab.ABAN8 AS AddressNumber,
TRIM(pro.ULUSER) AS UserId,
TRIM(ab.ABALPH) AS FullName,
ab.ABUPMJ AS LastUpdateDate,
ab.ABUPMT AS LastUpdateTime,
ROW_NUMBER() OVER (PARTITION BY ab.ABAN8 ORDER BY ab.ABUPMJ DESC, ab.ABUPMT DESC) RN
FROM {ProductionSchema}.F0101 ab
LEFT OUTER JOIN {ProductionSchema}.F0092 pro ON (ab.ABAN8 = pro.ULAN8)
WHERE ab.ABATE = 'Y'
)
SELECT AddressNumber,
UserId,
FullName,
LastUpdateDate,
LastUpdateTime
FROM USER_CTE
WHERE RN = 1";
/// <summary>
/// Gets all business units of specified type from production schema.
/// Type codes: 'WC' = Work Center, 'PC' = Profit Center, 'BR' = Branch
/// </summary>
public const string SqlGetBusinessUnits = @"
SELECT TRIM(wc.MCMCU) AS Code,
TRIM(wc.MCDL01) AS Description,
wc.MCUPMJ AS LastUpdateDate,
wc.MCUPMT AS LastUpdateTime
FROM {ProductionSchema}.F0006 wc
WHERE wc.MCSTYL = :typeCode";
/// <summary>
/// Gets business units of specified type updated since specified date from production schema.
/// </summary>
public const string SqlGetBusinessUnitsFiltered = SqlGetBusinessUnits + @"
AND (wc.MCUPMJ > :dateUpdated OR
(wc.MCUPMJ = :dateUpdated AND wc.MCUPMT >= :timeUpdated))";
/// <summary>
/// Gets all organization hierarchy records from production schema.
/// Maps work centers to profit centers and branches.
/// </summary>
public const string SqlGetOrgHierarchy = @"
SELECT TRIM(oh.IWMCUW) AS ProfitCenterCode,
TRIM(oh.IWMCU) AS WorkCenterCode,
TRIM(oh.IWMMCU) AS BranchCode,
oh.IWUPMJ AS LastUpdateDate,
oh.IWTDAY AS LastUpdateTime
FROM {ProductionSchema}.F30006 oh
WHERE TRIM(oh.IWMCU) IS NOT NULL AND
TRIM(oh.IWMMCU) IS NOT NULL";
/// <summary>
/// Gets org hierarchy records updated since specified date from production schema.
/// </summary>
public const string SqlGetOrgHierarchyFiltered = SqlGetOrgHierarchy + @"
AND (oh.IWUPMJ > :dateUpdated OR
(oh.IWUPMJ = :dateUpdated AND oh.IWTDAY >= :timeUpdated))";
/// <summary>
/// Gets all route masters from production schema.
/// </summary>
public const string SqlGetRouteMasters = @"
SELECT TRIM(rm.IRMMCU) AS BranchCode,
TRIM(rm.IRKITL) AS ItemNumber,
TRIM(rm.IRTRT) AS RoutingType,
rm.IROPSQ / 10.0 AS SequenceNumber,
TRIM(rm.IRURRF) AS FunctionCode,
TRIM(rm.IRMCU) AS WorkCenterCode,
rm.IREFFF AS StartDateDate,
rm.IREFFT AS EndDateDate,
rm.IRUPMJ AS LastUpdateDate,
rm.IRTDAY AS LastUpdateTime
FROM {ProductionSchema}.F3003 rm
WHERE TRIM(rm.IRKITL) IS NOT NULL";
/// <summary>
/// Gets route masters updated since specified date from production schema.
/// </summary>
public const string SqlGetRouteMastersFiltered = SqlGetRouteMasters + @"
AND (rm.IRUPMJ > :dateUpdated OR
(rm.IRUPMJ = :dateUpdated AND rm.IRTDAY >= :timeUpdated))";
}
@@ -0,0 +1,234 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Work order related SQL queries (Work Orders, Steps, Times, Routings, Components)
/// </summary>
public static partial class JdeQueries
{
/// <summary>
/// Gets all work orders from production schema.
/// </summary>
public const string SqlGetWorkorders = @"
SELECT wo.WADOCO AS WorkOrderNumber,
TRIM(wo.WAMMCU) AS BranchCode,
TRIM(wo.WALOTN) AS LotNumber,
TRIM(wo.WALITM) AS ItemNumber,
wo.WAITM AS ShortItemNumber,
TRIM(wo.WAPARS) AS ParentWorkOrderNumber,
wo.WAUORG / 100.0 AS OrderQuantity,
wo.WASOBK / 100.0 AS HeldQuantity,
wo.WASOQS / 100.0 AS ShippedQuantity,
TRIM(wo.WASRST) AS StatusCode,
CASE wo.WADCG WHEN 0 THEN TO_DATE('1900-01-01', 'YYYY-MM-DD')
ELSE TO_DATE(wo.WADCG+1900000,'YYYYDDD') END AS StatusCodeUpdateDT,
CASE wo.WATRDJ WHEN 0 THEN TO_DATE('1900-01-01', 'YYYY-MM-DD')
ELSE TO_DATE(wo.WATRDJ+1900000,'YYYYDDD') END AS IssueDate,
CASE wo.WASTRT WHEN 0 THEN TO_DATE('1900-01-01', 'YYYY-MM-DD')
ELSE TO_DATE(wo.WASTRT+1900000,'YYYYDDD') END AS StartDate,
TRIM(wo.WATRT) AS RoutingType,
wo.WAUPMJ AS LastUpdateDate,
wo.WATDAY AS LastUpdateTime
FROM {ProductionSchema}.F4801 wo";
/// <summary>
/// Gets work orders updated since specified date from production schema.
/// </summary>
public const string SqlGetWorkordersFiltered = SqlGetWorkorders + @"
WHERE (wo.WAUPMJ > :dateUpdated OR
(wo.WAUPMJ = :dateUpdated AND wo.WATDAY >= :timeUpdated))";
/// <summary>
/// Gets all work orders from archive schema.
/// </summary>
public const string SqlGetWorkordersArchive = @"
SELECT wo.WADOCO AS WorkOrderNumber,
TRIM(wo.WAMMCU) AS BranchCode,
TRIM(wo.WALOTN) AS LotNumber,
TRIM(wo.WALITM) AS ItemNumber,
wo.WAITM AS ShortItemNumber,
TRIM(wo.WAPARS) AS ParentWorkOrderNumber,
wo.WAUORG / 100.0 AS OrderQuantity,
wo.WASOBK / 100.0 AS HeldQuantity,
wo.WASOQS / 100.0 AS ShippedQuantity,
TRIM(wo.WASRST) AS StatusCode,
CASE wo.WADCG WHEN 0 THEN TO_DATE('1900-01-01', 'YYYY-MM-DD')
ELSE TO_DATE(wo.WADCG+1900000,'YYYYDDD') END AS StatusCodeUpdateDT,
CASE wo.WATRDJ WHEN 0 THEN TO_DATE('1900-01-01', 'YYYY-MM-DD')
ELSE TO_DATE(wo.WATRDJ+1900000,'YYYYDDD') END AS IssueDate,
CASE wo.WASTRT WHEN 0 THEN TO_DATE('1900-01-01', 'YYYY-MM-DD')
ELSE TO_DATE(wo.WASTRT+1900000,'YYYYDDD') END AS StartDate,
TRIM(wo.WATRT) AS RoutingType,
wo.WAUPMJ AS LastUpdateDate,
wo.WATDAY AS LastUpdateTime
FROM {ArchiveSchema}.F4801 wo";
/// <summary>
/// Gets work order steps from production schema.
/// </summary>
public const string SqlGetWorkorderSteps = @"
SELECT wos.WLDOCO AS WorkOrderNumber,
wos.WLOPSQ/10 AS StepNumber,
TRIM(wos.WLMCU) AS WorkCenterCode,
TRIM(wos.WLMMCU) AS BranchCode,
TRIM(wos.WLDSC1) AS StepDescription,
TRIM(mes.CFDS80) AS FunctionOperationDescription,
wos.WLOPSC AS StepTypeCode,
CASE wos.WLSTRT WHEN 0 THEN NULL
ELSE TO_DATE(wos.WLSTRT+1900000,'YYYYDDD') END AS StartDT,
CASE wos.WLSTRX WHEN 0 THEN NULL
ELSE TO_DATE(wos.WLSTRX+1900000,'YYYYDDD') END AS EndDT,
TRIM(wos.WLURRF) AS FunctionCode,
wos.WLSOCN / 100.0 AS ScrappedQuantity,
wos.WLUPMJ AS LastUpdateDate,
wos.WLTDAY AS LastUpdateTime
FROM {ProductionSchema}.F3112 wos
LEFT OUTER JOIN {ProductionSchema}.F00192 mes ON (wos.WLURRF = mes.CFKY)
WHERE TRIM(wos.WLMCU) IS NOT NULL AND
TRIM(wos.WLMMCU) IS NOT NULL";
/// <summary>
/// Gets work order steps updated since specified date from production schema.
/// </summary>
public const string SqlGetWorkorderStepsFiltered = SqlGetWorkorderSteps + @"
AND (wos.WLUPMJ > :dateUpdated OR
(wos.WLUPMJ = :dateUpdated AND wos.WLTDAY >= :timeUpdated))";
/// <summary>
/// Gets work order steps from archive schema.
/// </summary>
public const string SqlGetWorkorderStepsArchive = @"
SELECT wos.WLDOCO AS WorkOrderNumber,
wos.WLOPSQ/10 AS StepNumber,
TRIM(wos.WLMCU) AS WorkCenterCode,
TRIM(wos.WLMMCU) AS BranchCode,
TRIM(wos.WLDSC1) AS StepDescription,
TRIM(mes.CFDS80) AS FunctionOperationDescription,
wos.WLOPSC AS StepTypeCode,
CASE wos.WLSTRT WHEN 0 THEN NULL
ELSE TO_DATE(wos.WLSTRT+1900000,'YYYYDDD') END AS StartDT,
CASE wos.WLSTRX WHEN 0 THEN NULL
ELSE TO_DATE(wos.WLSTRX+1900000,'YYYYDDD') END AS EndDT,
TRIM(wos.WLURRF) AS FunctionCode,
wos.WLSOCN / 100.0 AS ScrappedQuantity,
wos.WLUPMJ AS LastUpdateDate,
wos.WLTDAY AS LastUpdateTime
FROM {ArchiveSchema}.F3112 wos
LEFT OUTER JOIN {ProductionSchema}.F00192 mes ON (wos.WLURRF = mes.CFKY)
WHERE TRIM(wos.WLMCU) IS NOT NULL AND
TRIM(wos.WLMMCU) IS NOT NULL";
/// <summary>
/// Gets work order time transactions from production schema.
/// </summary>
public const string SqlGetWorkorderTimes = @"
SELECT wot.WTUKID AS UniqueID,
wot.WTDOCO AS WorkOrderNumber,
wot.WTOPSQ/10 AS StepNumber,
TRIM(wot.WTMCU) AS WorkCenterCode,
TRIM(wot.WTMMCU) AS BranchCode,
wot.WTAN8 AS AddressNumber,
CASE wot.WTDGL WHEN 0 THEN NULL
ELSE TO_DATE(wot.WTDGL+1900000,'YYYYDDD') END AS GlDate,
wot.WTUPMJ AS LastUpdateDate,
wot.WTTDAY AS LastUpdateTime
FROM {ProductionSchema}.F31122 wot
WHERE TRIM(wot.WTMCU) IS NOT NULL AND
TRIM(wot.WTMMCU) IS NOT NULL";
/// <summary>
/// Gets work order times updated since specified date from production schema.
/// </summary>
public const string SqlGetWorkorderTimesFiltered = SqlGetWorkorderTimes + @"
AND (wot.WTUPMJ > :dateUpdated OR
(wot.WTUPMJ = :dateUpdated AND wot.WTTDAY >= :timeUpdated))";
/// <summary>
/// Gets work order time transactions from archive schema.
/// </summary>
public const string SqlGetWorkorderTimesArchive = @"
SELECT wot.WTUKID AS UniqueID,
wot.WTDOCO AS WorkOrderNumber,
wot.WTOPSQ/10 AS StepNumber,
TRIM(wot.WTMCU) AS WorkCenterCode,
TRIM(wot.WTMMCU) AS BranchCode,
wot.WTAN8 AS AddressNumber,
CASE wot.WTDGL WHEN 0 THEN NULL
ELSE TO_DATE(wot.WTDGL+1900000,'YYYYDDD') END AS GlDate,
wot.WTUPMJ AS LastUpdateDate,
wot.WTTDAY AS LastUpdateTime
FROM {ArchiveSchema}.F31122 wot
WHERE TRIM(wot.WTMCU) IS NOT NULL AND
TRIM(wot.WTMMCU) IS NOT NULL";
/// <summary>
/// Gets work order routing transactions from production schema.
/// </summary>
public const string SqlGetWorkorderRoutings = @"
SELECT TRIM(woz.SZEDUS) AS UserID,
TRIM(woz.SZEDBT) AS BatchNumber,
TRIM(woz.SZEDTN) AS TransactionNumber,
woz.SZEDLN AS LineNumber,
woz.SZOPSQ / 10.0 AS StepNumber,
TRIM(woz.SZMCU) AS WorkCenterCode,
woz.SZDOCO AS WorkOrderNumber,
TRIM(woz.SZTRT) AS RoutingType,
TRIM(woz.SZMMCU) AS BranchCode,
TRIM(woz.SZDSC1) AS StepDescription,
TRIM(woz.SZURRF) AS FunctionCode,
woz.SZTRDJ AS TransactionDate_Date,
woz.SZUPMJ AS LastUpdateDate,
woz.SZTDAY AS LastUpdateTime
FROM {ProductionSchema}.F3112Z1 woz
WHERE woz.SZTYTN = 'JDERTG' AND
woz.SZDRIN = '2' AND
woz.SZTNAC = '02' AND
woz.SZPID = 'ER31410' AND
TRIM(woz.SZEDUS) IS NOT NULL AND
TRIM(woz.SZEDBT) IS NOT NULL AND
TRIM(woz.SZEDTN) IS NOT NULL AND
TRIM(woz.SZMCU) IS NOT NULL";
/// <summary>
/// Gets work order routings updated since specified date from production schema.
/// </summary>
public const string SqlGetWorkorderRoutingsFiltered = SqlGetWorkorderRoutings + @"
AND (woz.SZUPMJ > :dateUpdated OR
(woz.SZUPMJ = :dateUpdated AND woz.SZTDAY >= :timeUpdated))";
/// <summary>
/// Gets work order component usage from production schema.
/// </summary>
public const string SqlGetWorkorderComponents = @"
SELECT woc.WMUKID AS UniqueID,
woc.WMDOCO AS WorkOrderNumber,
TRIM(woc.WMLOTN) AS LotNumber,
TRIM(woc.WMCMCU) AS BranchCode,
woc.WMCPIT AS ShortItemNumber,
woc.WMTRQT / 100.0 AS Quantity,
woc.WMUPMJ AS LastUpdateDate,
woc.WMTDAY AS LastUpdateTime
FROM {ProductionSchema}.F3111 woc
WHERE TRIM(woc.WMLOTN) IS NOT NULL";
/// <summary>
/// Gets work order components updated since specified date from production schema.
/// </summary>
public const string SqlGetWorkorderComponentsFiltered = SqlGetWorkorderComponents + @"
AND (woc.WMUPMJ > :dateUpdated OR
(woc.WMUPMJ = :dateUpdated AND woc.WMTDAY >= :timeUpdated))";
/// <summary>
/// Gets work order component usage from archive schema.
/// </summary>
public const string SqlGetWorkorderComponentsArchive = @"
SELECT woc.WMUKID AS UniqueID,
woc.WMDOCO AS WorkOrderNumber,
TRIM(woc.WMLOTN) AS LotNumber,
TRIM(woc.WMCMCU) AS BranchCode,
woc.WMCPIT AS ShortItemNumber,
woc.WMTRQT / 100.0 AS Quantity,
woc.WMUPMJ AS LastUpdateDate,
woc.WMTDAY AS LastUpdateTime
FROM {ArchiveSchema}.F3111 woc
WHERE TRIM(woc.WMLOTN) IS NOT NULL";
}
@@ -0,0 +1,9 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// SQL query constants for the JDE Oracle database.
/// Schema placeholders ({ProductionSchema}, {ArchiveSchema}, {StageSchema}) are replaced at runtime.
/// </summary>
public static partial class JdeQueries
{
}
@@ -0,0 +1,111 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Data sync and maintenance SQL queries
/// </summary>
public static partial class LotFinderQueries
{
/// <summary>
/// Gets the last data update record for each table/update type combination.
/// </summary>
public const string SqlGetLastDataUpdates = @"
WITH DU_CTE AS (
SELECT du.*,
ROW_NUMBER() OVER (PARTITION BY du.TableName, du.UpdateType ORDER BY du.StartDT DESC) RN
FROM dbo.DataUpdate AS du
)
SELECT cte.SourceSystem,
cte.SourceData,
cte.TableName,
cte.StartDT,
cte.EndDT,
cte.UpdateType,
cte.WasSuccessful,
cte.NumberRecords
FROM DU_CTE cte
WHERE cte.RN = 1";
/// <summary>
/// Gets column metadata for a table.
/// </summary>
public const string SqlGetTableColumns = @"
SELECT c.name AS Name,
CASE t2.name
WHEN 'varchar' THEN 'VARCHAR(' + CAST(c.max_length AS VARCHAR(10)) + ')'
WHEN 'decimal' THEN 'DECIMAL(' + CAST(c.precision AS VARCHAR(4)) + ',' + CAST(c.scale AS VARCHAR(4)) + ')'
ELSE UPPER(t2.name)
END AS Definition
FROM sys.columns c
INNER JOIN sys.types AS t2 ON (c.system_type_id = t2.system_type_id)
INNER JOIN sys.tables t ON (c.object_id = t.object_id)
WHERE t.name = @name
ORDER BY c.column_id";
/// <summary>
/// Gets primary key columns for a table.
/// </summary>
public const string SqlGetTablePrimaryKey = @"
SELECT COLUMN_NAME AS Name
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE OBJECTPROPERTY(OBJECT_ID(CONSTRAINT_SCHEMA + '.' + QUOTENAME(CONSTRAINT_NAME)), 'IsPrimaryKey') = 1 AND
TABLE_NAME = @name
ORDER BY ORDINAL_POSITION";
/// <summary>
/// Rebuilds all indices on a table. Use string.Format to inject table name.
/// </summary>
public const string SqlRebuildIndices = "ALTER INDEX ALL ON {0} REBUILD WITH (FILLFACTOR = 95);";
/// <summary>
/// Post-processing script to set MIS data obsoletion dates.
/// </summary>
public const string SqlPostprocessMisData = @"
SET ANSI_WARNINGS OFF;
WITH cte AS (
SELECT md.MisNumber, md.RevID, md.Status, MIN(md.ReleaseDate) Released
FROM dbo.MisData AS md
GROUP BY md.MisNumber, md.RevID, md.Status
)
UPDATE dbo.MisData
SET ObsoleteDate = bl.Released
FROM cte bl
WHERE MisData.MisNumber = bl.MisNumber AND
MisData.RevID = bl.RevID AND
MisData.Status = 'Current' AND
bl.Status = 'BackLevel';
WITH cte AS (
SELECT md.MisNumber, md.RevID, md.Status, MIN(md.ReleaseDate) Released
FROM dbo.MisData AS md
GROUP BY md.MisNumber, md.RevID, md.Status
)
UPDATE dbo.MisData
SET ObsoleteDate = (SELECT TOP 1 nl.Released
FROM cte nl
WHERE MisData.MisNumber = nl.MisNumber AND
MisData.RevID < nl.RevID AND
MisData.Status = nl.Status
ORDER BY nl.RevID)
WHERE ObsoleteDate IS NULL;
ALTER INDEX [PK_MisData] ON [dbo].[MisData] REBUILD;";
/// <summary>
/// Inserts a new data update tracking record.
/// </summary>
public const string SqlInsertDataUpdate = @"
INSERT INTO dbo.DataUpdate (SourceSystem, SourceData, TableName, StartDT, UpdateType)
VALUES (@sourceSystem, @sourceData, @tableName, @startDT, @updateType);
SELECT CAST(SCOPE_IDENTITY() AS BIGINT);";
/// <summary>
/// Completes a data update tracking record.
/// </summary>
public const string SqlCompleteDataUpdate = @"
UPDATE dbo.DataUpdate
SET EndDT = @endDT,
WasSuccessful = @wasSuccessful,
NumberRecords = @numberRecords
WHERE ID = @id";
}
@@ -0,0 +1,131 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Reference data lookup SQL queries
/// </summary>
public static partial class LotFinderQueries
{
/// <summary>
/// Searches items by number or description.
/// </summary>
public const string SqlSearchItems = @"
SELECT TOP 25
i.ShortItemNumber,
i.ItemNumber,
i.Description,
i.LastUpdateDT
FROM dbo.Item AS i
WHERE i.ItemNumber LIKE '%' + @filter + '%' OR
i.Description LIKE '%' + @filter + '%'
ORDER BY i.ItemNumber";
/// <summary>
/// Looks up items by exact item numbers using table-valued parameter.
/// </summary>
public const string SqlLookupItems = @"
SELECT i.ShortItemNumber,
i.ItemNumber,
i.Description,
i.LastUpdateDT
FROM dbo.Item AS i
INNER JOIN @itemNumbers AS i2 ON (i.ItemNumber = i2.ItemNumber)
ORDER BY i.ItemNumber";
/// <summary>
/// Looks up work orders by work order numbers using table-valued parameter.
/// </summary>
public const string SqlLookupWorkorders = @"
SELECT *
FROM dbo.WorkOrder AS wo
INNER JOIN @workOrderNumbers wo2 ON (wo.WorkOrderNumber = wo2.WorkOrderNumber)";
/// <summary>
/// Searches work centers by code or description.
/// </summary>
public const string SqlSearchWorkCenters = @"
SELECT TOP 25
wc.Code,
wc.Description,
wc.LastUpdateDT
FROM dbo.WorkCenter AS wc
WHERE wc.Code LIKE '%' + @filter + '%' OR
wc.Description LIKE '%' + @filter + '%'
ORDER BY wc.Code";
/// <summary>
/// Looks up work centers by codes using table-valued parameter.
/// </summary>
public const string SqlLookupWorkCenters = @"
SELECT wc.Code,
wc.Description,
wc.LastUpdateDT
FROM dbo.WorkCenter AS wc
INNER JOIN @workCenterCodes wc2 ON (wc.Code = wc2.Code)
ORDER BY wc.Code";
/// <summary>
/// Searches profit centers by code or description.
/// </summary>
public const string SqlSearchProfitCenters = @"
SELECT TOP 25
pc.Code,
pc.Description,
pc.LastUpdateDT
FROM dbo.ProfitCenter AS pc
WHERE pc.Code LIKE '%' + @filter + '%' OR
pc.Description LIKE '%' + @filter + '%'
ORDER BY pc.Code";
/// <summary>
/// Looks up profit centers by codes using table-valued parameter.
/// </summary>
public const string SqlLookupProfitCenters = @"
SELECT pc.Code,
pc.Description,
pc.LastUpdateDT
FROM dbo.ProfitCenter AS pc
INNER JOIN @profitCenterCodes AS pc2 ON (pc.Code = pc2.Code)
ORDER BY pc.Code";
/// <summary>
/// Searches users by user ID, full name, or address number.
/// </summary>
public const string SqlSearchUsers = @"
SELECT TOP 25
u.AddressNumber,
COALESCE(u.UserID,' ') AS UserID,
u.FullName,
u.LastUpdateDT
FROM dbo.JdeUser AS u
WHERE u.UserID LIKE '%' + @filter + '%' OR
u.FullName LIKE '%' + @filter + '%' OR
CAST(u.AddressNumber AS VARCHAR(10)) LIKE '%' + @filter + '%'
ORDER BY u.UserID, u.FullName";
/// <summary>
/// Looks up users by user IDs or address numbers using table-valued parameter.
/// </summary>
public const string SqlLookupUsers = @"
SELECT u.AddressNumber,
u.UserID,
u.FullName,
u.LastUpdateDT
FROM dbo.JdeUser AS u
INNER JOIN @userIDs u2 ON (u.UserID = u2.UserName OR CAST(u.AddressNumber AS VARCHAR(20)) = u2.UserName)
ORDER BY u.UserID";
/// <summary>
/// Looks up lots by lot number and item number using table-valued parameter.
/// </summary>
public const string SqlLookupLots = @"
SELECT DISTINCT
l.LotNumber,
l.BranchCode,
l.ShortItemNumber,
l.ItemNumber,
l.SupplierCode,
l.LastUpdateDT
FROM dbo.Lot AS l
INNER JOIN @lotNumbers ln ON (l.LotNumber = ln.ComponentLotNumber AND
((l.ItemNumber IS NULL AND ln.ItemNumber IS NULL) OR l.ItemNumber = ln.ItemNumber))";
}
@@ -0,0 +1,76 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// Search management SQL queries
/// </summary>
public static partial class LotFinderQueries
{
/// <summary>
/// Gets a user's searches (lightweight - no criteria or results).
/// </summary>
public const string SqlGetUserSearches = @"
SELECT s.ID,
s.Name,
s.Status,
s.SubmitDT,
s.StartDT,
s.EndDT
FROM dbo.Search s
WHERE s.UserName = @userName
ORDER BY s.SubmitDT";
/// <summary>
/// Gets all queued searches (Status less than 3 = New, Submitted, Started).
/// </summary>
public const string SqlGetQueuedSearches = @"
SELECT s.ID,
s.UserName,
s.Name,
s.Status,
s.SubmitDT,
s.StartDT,
s.EndDT
FROM dbo.Search s
WHERE s.Status < 3
ORDER BY s.SubmitDT";
/// <summary>
/// Gets a single search by ID with criteria.
/// </summary>
public const string SqlGetSearch = @"
SELECT s.UserName,
s.Name,
s.Status,
s.SubmitDT,
s.StartDT,
s.EndDT,
s.Criteria as CriteriaJSON
FROM dbo.Search s
WHERE s.ID = @id";
/// <summary>
/// Gets the binary results for a search.
/// </summary>
public const string SqlGetSearchResults = @"
SELECT s.Results
FROM dbo.Search AS s
WHERE s.ID = @id";
/// <summary>
/// Updates search status.
/// </summary>
public const string SqlUpdateSearchStatus = @"
UPDATE dbo.Search
SET Status = @status,
StartDT = CASE WHEN @status = 2 THEN GETUTCDATE() ELSE StartDT END,
EndDT = CASE WHEN @status >= 3 THEN GETUTCDATE() ELSE EndDT END
WHERE ID = @id";
/// <summary>
/// Updates search results.
/// </summary>
public const string SqlUpdateSearchResults = @"
UPDATE dbo.Search
SET Results = @results
WHERE ID = @id";
}
@@ -0,0 +1,8 @@
namespace JdeScoping.DataAccess.Queries;
/// <summary>
/// SQL query constants for the LotFinder SQL Server cache database.
/// </summary>
public static partial class LotFinderQueries
{
}
@@ -0,0 +1,203 @@
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.QueryBuilders;
/// <summary>
/// Builds MIS extraction queries for work order step matching.
/// </summary>
public sealed class MisQueryBuilder
{
private readonly SqlServerCompiler _compiler;
/// <summary>
/// Initializes a new instance of MisQueryBuilder.
/// </summary>
/// <param name="compiler">The SqlKata SQL Server compiler.</param>
public MisQueryBuilder(SqlServerCompiler compiler)
{
_compiler = compiler;
}
/// <summary>
/// Builds the complete MIS extraction SQL including temp table setup and data population.
/// </summary>
/// <param name="model">The search model containing filter criteria.</param>
/// <returns>The SQL statements for MIS extraction.</returns>
public IReadOnlyList<string> BuildMisExtractionSql(SearchModel model)
{
var statements = new List<string>();
// Create #TempMisData temp table
statements.Add(BuildTempMisDataTableSql());
// Build and execute MIS CTE query to populate temp table
statements.Add(BuildMisCteSql(model));
return statements;
}
private static string BuildTempMisDataTableSql()
{
return """
IF OBJECT_ID('tempdb.dbo.#TempMisData', 'U') IS NOT NULL
BEGIN
DROP TABLE #TempMisData;
END
CREATE TABLE #TempMisData (
WorkOrderNumber BIGINT,
ItemNumber VARCHAR(25),
ItemDescription VARCHAR(30),
BranchCode VARCHAR(12),
WorkCenterCode VARCHAR(12),
StepTimestamp DATETIME,
SequenceNumber DECIMAL(7, 2),
FunctionCode VARCHAR(15),
FunctionOperationDescription VARCHAR(80),
MatchedSequenceNumber DECIMAL(7, 2),
RoutingMatch BIT,
MasterMatch BIT,
MisNumber VARCHAR(32),
RevID VARCHAR(32),
CharNumber VARCHAR(32),
MisSequenceNumber VARCHAR(32),
TestDescription VARCHAR(2000),
SamplingType VARCHAR(32),
SamplingValue VARCHAR(32),
ToolsGauges VARCHAR(2000),
WorkInstructions VARCHAR(2000),
Status VARCHAR(32),
ReleaseDate DATETIME
);
""";
}
private string BuildMisCteSql(SearchModel model)
{
var joins = BuildMisJoins(model);
var whereClause = BuildMisWhereClause(model);
return $"""
WITH MIS_CTE AS(
SELECT DISTINCT wo.WorkOrderNumber,
wo.ItemNumber,
wo.BranchCode,
wo.RoutingType,
wo.IssueDate,
wos.WorkCenterCode,
wos.StepNumber,
wos.EndDT,
wos.FunctionCode,
wos.FunctionOperationDescription
FROM dbo.WorkOrderStep AS wos INNER JOIN
dbo.WorkOrder AS wo ON (wos.WorkOrderNumber = wo.WorkOrderNumber AND LTRIM(wos.BranchCode) = wo.BranchCode) LEFT OUTER JOIN
dbo.WorkOrderTime AS wot ON (wos.WorkOrderNumber = wot.WorkOrderNumber AND LTRIM(wos.BranchCode) = wot.BranchCode AND wos.StepNumber = wot.StepNumber){joins}{whereClause}
)
INSERT INTO #TempMISData
(
WorkOrderNumber,
ItemNumber,
ItemDescription,
BranchCode,
WorkCenterCode,
StepTimestamp,
SequenceNumber,
FunctionCode,
FunctionOperationDescription,
MatchedSequenceNumber,
RoutingMatch,
MasterMatch,
MisNumber,
RevID,
CharNumber,
MisSequenceNumber,
TestDescription,
SamplingType,
SamplingValue,
ToolsGauges,
WorkInstructions,
Status,
ReleaseDate
)
SELECT mm.WorkOrderNumber,
mm.ItemNumber,
mm.ItemDescription,
mm.BranchCode,
mm.WorkCenterCode,
mm.StepTimestamp,
mm.SequenceNumber,
mm.FunctionCode,
mm.FunctionOperationDescription,
mm.MatchedSequenceNumber,
mm.RoutingMatch,
mm.MasterMatch,
mm.MisNumber,
mm.RevID,
mm.CharNumber,
mm.MisSequenceNumber,
mm.TestDescription,
mm.SamplingType,
mm.SamplingValue,
mm.ToolsGauges,
mm.WorkInstructions,
mm.Status,
mm.ReleaseDate
FROM MIS_CTE c CROSS APPLY
dbo.MatchMIS(c.WorkOrderNumber, c.ItemNumber, c.BranchCode, c.RoutingType,
c.IssueDate, c.WorkCenterCode, c.StepNumber, c.EndDT,
c.FunctionCode, c.FunctionOperationDescription) AS mm;
""";
}
private static string BuildMisJoins(SearchModel model)
{
var joins = new List<string>();
if (model.ItemNumberFilterEnabled)
{
joins.Add(" INNER JOIN\r\n #P_ItemNumbers p_in ON (wo.ItemNumber = p_in.ItemNumber)");
}
if (model.ProfitCenterFilterEnabled || model.WorkCenterFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_WorkCenters p_wc ON (wos.WorkCenterCode = p_wc.Code)");
}
if (model.OperatorFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_OperatorIDs p_oi ON (wot.AddressNumber = p_oi.AddressNumber)");
}
return string.Join("", joins);
}
private static string BuildMisWhereClause(SearchModel model)
{
if (model.MinimumDt.HasValue && model.MaximumDt.HasValue)
{
return """
WHERE (((wos.EndDT <= @p_MaximumDT) AND (wos.EndDT >= @p_MinimumDT)) OR
((wot.GlDate <= @p_MaximumDT) AND (wot.GlDate >= @p_MinimumDT)))
""";
}
if (model.MinimumDt.HasValue && !model.MaximumDt.HasValue)
{
return """
WHERE (wos.EndDT >= @p_MinimumDT OR wot.GlDate >= @p_MinimumDT)
""";
}
if (!model.MinimumDt.HasValue && model.MaximumDt.HasValue)
{
return """
WHERE (wos.EndDT <= @p_MaximumDT OR wot.GlDate <= @p_MaximumDT)
""";
}
return "";
}
}
@@ -0,0 +1,365 @@
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Models;
using SqlKata.Compilers;
namespace JdeScoping.DataAccess.QueryBuilders;
/// <summary>
/// SqlKata-based implementation of ISearchQueryBuilder.
/// Builds SQL queries using a fluent query builder pattern.
/// </summary>
public sealed class SqlKataSearchQueryBuilder : ISearchQueryBuilder
{
private readonly SqlServerCompiler _compiler;
private readonly IEnumerable<IFilterHandler> _filterHandlers;
/// <summary>
/// Initializes a new instance of SqlKataSearchQueryBuilder.
/// </summary>
/// <param name="compiler">The SqlKata SQL Server compiler.</param>
/// <param name="filterHandlers">Collection of filter handlers.</param>
public SqlKataSearchQueryBuilder(
SqlServerCompiler compiler,
IEnumerable<IFilterHandler> filterHandlers)
{
_compiler = compiler;
_filterHandlers = filterHandlers.OrderBy(h => h.Priority);
}
/// <inheritdoc />
public SearchQueryResult BuildSearchQuery(SearchModel model)
{
var setupStatements = new List<string>();
var parameters = new Dictionary<string, object>();
// 1. Create the #Temp_WO temp table
setupStatements.Add(BuildTempWoTableSql());
// 2. Apply filter handlers in priority order
foreach (var handler in _filterHandlers.Where(h => h.IsEnabled(model)))
{
var filterResult = handler.Apply(model, _compiler);
setupStatements.AddRange(filterResult.SetupSql);
foreach (var param in filterResult.Parameters)
{
parameters[param.Key] = param.Value;
}
}
// 3. Build step-based flagging query if needed
if (model.ShouldSearchSteps())
{
setupStatements.Add(BuildStepFlaggingQuery(model));
}
// 4. Build the final result SELECT query
var resultSql = BuildResultQuery();
return new SearchQueryResult(resultSql, parameters, setupStatements);
}
/// <inheritdoc />
public SearchQueryResult BuildMisQuery(SearchModel model)
{
// MIS query is delegated to MisQueryBuilder
// This is a placeholder - full implementation would generate MIS extraction SQL
var parameters = new Dictionary<string, object>();
if (model.MinimumDt.HasValue)
{
parameters["p_MinimumDT"] = model.MinimumDt.Value;
}
if (model.MaximumDt.HasValue)
{
parameters["p_MaximumDT"] = model.MaximumDt.Value;
}
var sql = BuildMisExtractionQuery(model);
return new SearchQueryResult(sql, parameters, []);
}
/// <inheritdoc />
public SearchQueryResult BuildMisNonMatchQuery(SearchModel model)
{
var sql = BuildMisNonMatchExtractionQuery();
return new SearchQueryResult(sql, new Dictionary<string, object>(), []);
}
private static string BuildTempWoTableSql()
{
return """
--Setup flagged work order temp table
IF OBJECT_ID('tempdb.dbo.#Temp_WO', 'U') IS NOT NULL
BEGIN
DROP TABLE #Temp_WO;
END
CREATE TABLE #Temp_WO (
WorkOrderNumber BIGINT NOT NULL,
LotNumber VARCHAR(30) NULL,
BranchCode VARCHAR(12) NULL,
ShortItemNumber BIGINT NOT NULL,
ManuallySpecified BIT NOT NULL DEFAULT 0,
SplitOrder BIT NOT NULL DEFAULT 0,
CARDEX BIT NOT NULL DEFAULT 0,
PartsList BIT NOT NULL DEFAULT 0,
Flagged BIT NOT NULL DEFAULT 0,
PRIMARY KEY CLUSTERED(WorkOrderNumber)
);
CREATE INDEX TIX_Temp_WO_Lookup ON #Temp_WO(LotNumber, BranchCode, ShortItemNumber);
""";
}
private string BuildStepFlaggingQuery(SearchModel model)
{
// Build the complex step-based flagging query dynamically
var queryParts = new List<string>();
// First query part - WorkOrderStep based
queryParts.Add(BuildStepSubquery(model, includeWorkOrderTime: true));
// Second query part - WorkOrderTime only (if not filtering by MIS)
if (!model.ItemOperationMisFilterEnabled)
{
queryParts.Add(BuildTimeOnlySubquery(model));
}
var unionQuery = string.Join("\r\n UNION \r\n", queryParts);
return $"""
--Query data
WITH LU_WO AS(
SELECT DISTINCT
step.WorkOrderNumber,
step.LotNumber,
step.BranchCode,
step.ShortItemNumber
FROM (
{unionQuery}
) step
)
MERGE INTO #Temp_WO AS TARGET
USING LU_WO AS SOURCE
ON (TARGET.WorkOrderNumber = SOURCE.WorkOrderNumber AND TARGET.BranchCode = SOURCE.BranchCode)
WHEN MATCHED THEN
UPDATE SET TARGET.Flagged = 1
WHEN NOT MATCHED BY TARGET THEN
INSERT(WorkOrderNumber, LotNumber, BranchCode, ShortItemNumber, Flagged)
VALUES(SOURCE.WorkOrderNumber, SOURCE.LotNumber, SOURCE.BranchCode, SOURCE.ShortItemNumber, 1);
""";
}
private string BuildStepSubquery(SearchModel model, bool includeWorkOrderTime)
{
var joins = new List<string>();
var whereClause = "";
// Item number filter join
if (model.ItemNumberFilterEnabled)
{
joins.Add(" #P_ItemNumbers p_in ON (wo.ItemNumber = p_in.ItemNumber) INNER JOIN");
}
// Base WorkOrderStep join
joins.Add(" dbo.WorkOrderStep wos ON (wo.WorkOrderNumber = wos.WorkOrderNumber AND wo.BranchCode = wos.BranchCode) LEFT OUTER JOIN");
joins.Add(" dbo.WorkOrderTime wot ON (wos.WorkOrderNumber = wot.WorkOrderNumber AND wos.StepNumber = wot.StepNumber AND wos.BranchCode = wot.BranchCode)");
// Work center filter join
if (model.ProfitCenterFilterEnabled || model.WorkCenterFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_WorkCenters p_wc ON (wos.WorkCenterCode = p_wc.Code)");
}
// Operator filter join
if (model.OperatorFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_OperatorIDs p_oi ON (wot.AddressNumber = p_oi.AddressNumber)");
}
// MIS filter join (uses CROSS APPLY)
if (model.ItemOperationMisFilterEnabled)
{
joins.Add("""
CROSS APPLY
dbo.MatchMIS(wo.WorkOrderNumber, wo.ItemNumber, wo.BranchCode, wo.RoutingType, wo.IssueDate, wos.WorkCenterCode,
wos.StepNumber, wos.EndDT, wos.FunctionCode, wos.FunctionOperationDescription) AS mm INNER JOIN
#P_PartOperations p_po on (mm.ItemNumber = p_po.ItemNumber AND mm.MisSequenceNumber = p_po.OperationNumber AND mm.MisNumber = p_po.MisNumber AND mm.RevID = p_po.MisRevision)
""");
}
// Timespan WHERE clause
if (model.TimespanFilterEnabled)
{
whereClause = """
WHERE (wos.EndDT <= @p_MaximumDT AND wos.EndDT >= @p_MinimumDT) OR
(wot.GlDate <= @p_MaximumDT AND wot.GlDate >= @p_MinimumDT)
""";
}
return $"""
SELECT DISTINCT
wo.WorkOrderNumber,
COALESCE(wo.LotNumber, CAST(wo.WorkOrderNumber AS VARCHAR(8))) AS LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder wo INNER JOIN
{string.Join("\r\n", joins)}
{whereClause}
""";
}
private string BuildTimeOnlySubquery(SearchModel model)
{
var joins = new List<string>();
var whereClause = "";
// Item number filter join
if (model.ItemNumberFilterEnabled)
{
joins.Add(" #P_ItemNumbers p_in ON (wo.ItemNumber = p_in.ItemNumber) INNER JOIN");
}
// Base WorkOrderTime join
joins.Add(" dbo.WorkOrderTime wot ON (wo.WorkOrderNumber = wot.WorkOrderNumber AND wo.BranchCode = wot.BranchCode)");
// Work center filter join
if (model.ProfitCenterFilterEnabled || model.WorkCenterFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_WorkCenters p_wc ON (wot.WorkCenterCode = p_wc.Code)");
}
// Operator filter join
if (model.OperatorFilterEnabled)
{
joins.Add(" INNER JOIN \r\n #P_OperatorIDs p_oi ON (wot.AddressNumber = p_oi.AddressNumber)");
}
// Timespan WHERE clause (only if both min and max are present)
if (model.MinimumDt.HasValue && model.MaximumDt.HasValue)
{
whereClause = """
WHERE (wot.GlDate <= @p_MaximumDT AND wot.GlDate >= @p_MinimumDT)
""";
}
return $"""
SELECT DISTINCT
wo.WorkOrderNumber,
COALESCE(wo.LotNumber, CAST(wo.WorkOrderNumber AS VARCHAR(8))) AS LotNumber,
wo.BranchCode,
wo.ShortItemNumber
FROM dbo.WorkOrder wo INNER JOIN
{string.Join("\r\n", joins)}
{whereClause}
""";
}
private static string BuildResultQuery()
{
return """
--Lookup flagged work order details
WITH LAST_WOS AS
(
SELECT wos.WorkOrderNumber,
wos.BranchCode,
wos.StepNumber,
wos.StepDescription,
wos.FunctionOperationDescription,
wos.LastUpdateDT,
ROW_NUMBER() OVER (PARTITION BY wos.WorkOrderNumber, wos.BranchCode ORDER BY wos.EndDT DESC, wos.StepNumber DESC) AS RN
FROM dbo.WorkOrderStep AS wos LEFT OUTER JOIN
#Temp_WO t_wo ON (wos.WorkOrderNumber = t_wo.WorkOrderNumber AND wos.BranchCode = t_wo.BranchCode)
)
SELECT wo.WorkOrderNumber,
wo.BranchCode AS WorkOrderBranchCode,
wo.LotNumber,
wo.ItemNumber,
i.PlanningFamily,
i.StockingType,
t_wo.ManuallySpecified,
t_wo.SplitOrder,
t_wo.CARDEX,
t_wo.PartsList,
t_wo.Flagged,
wo.OrderQuantity,
wo.HeldQuantity,
COALESCE(wots.TotalScrappedQuantity, 0) AS ScrappedQuantity,
wo.ShippedQuantity,
LTRIM(RTRIM(lwos.BranchCode)) AS StepBranchCode,
lwos.StepNumber,
lwos.StepDescription,
lwos.FunctionOperationDescription,
lwos.LastUpdateDT AS StepUpdateDT,
wo.StatusCode,
sc.Description AS StatusDescription,
wo.StatusCodeUpdateDT AS StatusUpdateDT
FROM dbo.WorkOrder AS wo INNER JOIN
#Temp_WO AS t_wo ON (t_wo.WorkOrderNumber = wo.WorkOrderNumber AND t_wo.BranchCode = wo.BranchCode) LEFT OUTER JOIN
dbo.Item i on (wo.ShortItemNumber = i.ShortItemNumber) INNER JOIN
dbo.StatusCode AS sc ON (sc.Code = wo.StatusCode) LEFT OUTER JOIN
LAST_WOS lwos ON (wo.WorkOrderNumber = lwos.WorkOrderNumber AND wo.BranchCode = lwos.BranchCode) LEFT OUTER JOIN
dbo.WorkOrderTotalScrap wots ON (wo.WorkOrderNumber = wots.WorkOrderNumber AND wo.BranchCode = wots.BranchCode)
WHERE lwos.RN IS NULL OR
lwos.RN = 1;
""";
}
private static string BuildMisExtractionQuery(SearchModel model)
{
return """
--Get MIS search results
SELECT DISTINCT tmd.ItemNumber,
tmd.ItemDescription,
tmd.BranchCode,
tmd.MisSequenceNumber AS SequenceNumber,
tmd.FunctionCode,
tmd.FunctionOperationDescription,
tmd.SequenceNumber AS JobStepSequenceNumber,
tmd.MatchedSequenceNumber,
tmd.RoutingMatch,
tmd.MasterMatch,
tmd.MisNumber,
tmd.RevID,
tmd.CharNumber,
tmd.TestDescription,
tmd.SamplingType,
tmd.SamplingValue,
tmd.ToolsGauges,
tmd.WorkInstructions,
tmd.Status,
tmd.ReleaseDate
FROM #TempMisData AS tmd
ORDER BY tmd.ItemNumber,
tmd.BranchCode,
tmd.SequenceNumber,
tmd.MatchedSequenceNumber;
""";
}
private static string BuildMisNonMatchExtractionQuery()
{
return """
--Get no-match MIS search results
SELECT DISTINCT tmd.WorkCenterCode,
tmd.WorkOrderNumber,
wo.IssueDate AS WorkOrderStartDate,
tmd.SequenceNumber AS JobStepNumber,
tmd.FunctionOperationDescription AS JobStepDescription,
tmd.StepTimestamp AS JobStepEndDate,
CASE WHEN wo.RoutingType='NMR' OR NOT EXISTS(SELECT * FROM dbo.WorkOrderRouting wor WHERE tmd.WorkOrderNumber = wor.WorkOrderNumber AND wor.StepNumber = tmd.SequenceNumber) THEN 1 ELSE 0 END AS WasJobStepAdded,
CASE WHEN wo.RoutingType='NMR' THEN NULL ELSE (SELECT TOP 1 wor.StepNumber FROM dbo.WorkOrderRouting wor WHERE (tmd.WorkOrderNumber = wor.WorkOrderNumber AND tmd.WorkCenterCode = wor.WorkCenterCode AND tmd.FunctionCode = wor.FunctionCode) AND wor.StepNumber <> tmd.SequenceNumber) END AS MatchedJobStepNumber,
tmd.FunctionCode,
tmd.ItemNumber,
i.Description AS ItemDescription,
wo.RoutingType
FROM #TempMisData AS tmd INNER JOIN
dbo.WorkOrder AS wo ON (tmd.WorkOrderNumber = wo.WorkOrderNumber AND tmd.BranchCode = wo.BranchCode) LEFT OUTER JOIN
dbo.Item AS i ON (wo.ShortItemNumber = i.ShortItemNumber)
WHERE (tmd.RoutingMatch = 0 AND
tmd.MasterMatch = 0) OR
tmd.MisNumber IS NULL
ORDER BY tmd.WorkOrderNumber,
tmd.SequenceNumber;
""";
}
}
@@ -0,0 +1,83 @@
using System.Runtime.CompilerServices;
using Dapper;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Quality;
using JdeScoping.DataAccess.Configuration;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.Queries;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Repository implementation for the CMS Oracle database.
/// </summary>
public class CmsRepository : ICmsRepository
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<CmsRepository> _logger;
private readonly IOptions<DataAccessOptions> _options;
private const string RepositoryName = "CmsRepository";
/// <summary>
/// Initializes a new instance of the <see cref="CmsRepository"/> class.
/// </summary>
public CmsRepository(
IDbConnectionFactory connectionFactory,
ILogger<CmsRepository> logger,
IOptions<DataAccessOptions> options)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc/>
public async IAsyncEnumerable<MisData> GetMisDataAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = lastUpdateDt.HasValue
? CmsQueries.SqlGetMisDataFiltered
: CmsQueries.SqlGetMisData;
var parameters = lastUpdateDt.HasValue
? new { lastUpdateDT = lastUpdateDt.Value }
: null;
OracleConnection? connection = null;
try
{
connection = await _connectionFactory.CreateCmsConnectionAsync(ct);
// Use Query with buffered: false for streaming
var results = connection.Query<MisData>(
sql,
parameters,
commandTimeout: _options.Value.MisDataTimeoutSeconds,
buffered: false);
foreach (var item in results)
{
ct.ThrowIfCancellationRequested();
// Convert ReleaseDate to local time if present
if (item.ReleaseDate.HasValue)
{
item.ReleaseDate = item.ReleaseDate.Value.ToLocalTime();
}
yield return item;
}
}
finally
{
if (connection != null)
{
await connection.DisposeAsync();
}
}
}
}
@@ -0,0 +1,94 @@
using System.Runtime.CompilerServices;
using JdeScoping.Core.Helpers;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.DataAccess.Queries;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Inventory (lots) operations for JDE Oracle repository.
/// </summary>
public partial class JdeRepository
{
/// <inheritdoc/>
public async IAsyncEnumerable<Lot> GetLotsAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetLotsFiltered
: JdeQueries.SqlGetLots);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<Lot>(
sql, parameters, nameof(GetLotsAsync), "SQL_GET_LOTS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<LotUsage> GetLotUsagesAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetLotUsagesFiltered
: JdeQueries.SqlGetLotUsages);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
// Use special lot usage timeout due to large dataset
await foreach (var item in StreamQueryAsync<LotUsage>(
sql, parameters, nameof(GetLotUsagesAsync), "SQL_GET_LOT_USAGES", ct,
_options.Value.LotUsageTimeoutSeconds))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<LotUsage> GetLotUsagesArchiveAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(JdeQueries.SqlGetLotUsagesArchive);
// Use special lot usage timeout due to large dataset
await foreach (var item in StreamQueryAsync<LotUsage>(
sql, null, nameof(GetLotUsagesArchiveAsync), "SQL_GET_LOT_USAGES_ARCHIVE", ct,
_options.Value.LotUsageTimeoutSeconds))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<LotLocation> GetLotLocationsAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetLotLocationsFiltered
: JdeQueries.SqlGetLotLocations);
var parameters = lastUpdateDt.HasValue
? new { lastUpdateDT = lastUpdateDt.Value }
: null;
// Use JDE Stage connection for lot locations
await foreach (var item in StreamQueryFromStageAsync<LotLocation>(
sql, parameters, nameof(GetLotLocationsAsync), "SQL_GET_LOT_LOCATIONS", ct))
{
yield return item;
}
}
}
@@ -0,0 +1,191 @@
using System.Runtime.CompilerServices;
using JdeScoping.Core.Helpers;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Lookup;
using JdeScoping.Core.Models.Organization;
using JdeScoping.DataAccess.Queries;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Reference data operations for JDE Oracle repository.
/// </summary>
public partial class JdeRepository
{
/// <inheritdoc/>
public async IAsyncEnumerable<Item> GetItemsAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetItemsFiltered
: JdeQueries.SqlGetItems);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<Item>(
sql, parameters, nameof(GetItemsAsync), "SQL_GET_ITEMS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<JdeUser> GetUsersAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
// Users always do full sync (incremental not supported)
var sql = ApplySchemaReplacements(JdeQueries.SqlGetUsers);
await foreach (var item in StreamQueryAsync<JdeUser>(
sql, null, nameof(GetUsersAsync), "SQL_GET_USERS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Branch> GetBranchesAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetBusinessUnitsFiltered
: JdeQueries.SqlGetBusinessUnits);
var parameters = lastUpdateDt.HasValue
? new { typeCode = "BP", dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: new { typeCode = "BP", dateUpdated = 0, timeUpdated = 0 };
await foreach (var item in StreamQueryAsync<Branch>(
sql, parameters, nameof(GetBranchesAsync), "SQL_GET_BUSINESS_UNITS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<ProfitCenter> GetProfitCentersAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetBusinessUnitsFiltered
: JdeQueries.SqlGetBusinessUnits);
var parameters = lastUpdateDt.HasValue
? new { typeCode = "I3", dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: new { typeCode = "I3", dateUpdated = 0, timeUpdated = 0 };
await foreach (var item in StreamQueryAsync<ProfitCenter>(
sql, parameters, nameof(GetProfitCentersAsync), "SQL_GET_BUSINESS_UNITS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkCenter> GetWorkCentersAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetBusinessUnitsFiltered
: JdeQueries.SqlGetBusinessUnits);
var parameters = lastUpdateDt.HasValue
? new { typeCode = "WC", dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: new { typeCode = "WC", dateUpdated = 0, timeUpdated = 0 };
await foreach (var item in StreamQueryAsync<WorkCenter>(
sql, parameters, nameof(GetWorkCentersAsync), "SQL_GET_BUSINESS_UNITS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<StatusCode> GetStatusCodesAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetStatusCodesFiltered
: JdeQueries.SqlGetStatusCodes);
var parameters = lastUpdateDt.HasValue
? new { lastUpdateDT = lastUpdateDt.Value }
: null;
// Use JDE Stage connection for status codes
await foreach (var item in StreamQueryFromStageAsync<StatusCode>(
sql, parameters, nameof(GetStatusCodesAsync), "SQL_GET_STATUS_CODES", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<FunctionCode> GetFunctionCodesAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(JdeQueries.SqlGetFunctionCodes);
await foreach (var item in StreamQueryAsync<FunctionCode>(
sql, null, nameof(GetFunctionCodesAsync), "SQL_GET_FUNCTION_CODES", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<OrgHierarchy> GetOrgHierarchyAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetOrgHierarchyFiltered
: JdeQueries.SqlGetOrgHierarchy);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<OrgHierarchy>(
sql, parameters, nameof(GetOrgHierarchyAsync), "SQL_GET_ORG_HIERARCHY", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<RouteMaster> GetRouteMastersAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetRouteMastersFiltered
: JdeQueries.SqlGetRouteMasters);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<RouteMaster>(
sql, parameters, nameof(GetRouteMastersAsync), "SQL_GET_ROUTE_MASTERS", ct))
{
yield return item;
}
}
}
@@ -0,0 +1,173 @@
using System.Runtime.CompilerServices;
using JdeScoping.Core.Helpers;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.DataAccess.Queries;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Work order operations for JDE Oracle repository.
/// </summary>
public partial class JdeRepository
{
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetWorkordersFiltered
: JdeQueries.SqlGetWorkorders);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<WorkOrder>(
sql, parameters, nameof(GetWorkOrdersAsync), "SQL_GET_WORKORDERS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrder> GetWorkOrdersArchiveAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(JdeQueries.SqlGetWorkordersArchive);
await foreach (var item in StreamQueryAsync<WorkOrder>(
sql, null, nameof(GetWorkOrdersArchiveAsync), "SQL_GET_WORKORDERS_ARCHIVE", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetWorkorderStepsFiltered
: JdeQueries.SqlGetWorkorderSteps);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<WorkOrderStep>(
sql, parameters, nameof(GetWorkOrderStepsAsync), "SQL_GET_WORKORDER_STEPS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsArchiveAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(JdeQueries.SqlGetWorkorderStepsArchive);
await foreach (var item in StreamQueryAsync<WorkOrderStep>(
sql, null, nameof(GetWorkOrderStepsArchiveAsync), "SQL_GET_WORKORDER_STEPS_ARCHIVE", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetWorkorderTimesFiltered
: JdeQueries.SqlGetWorkorderTimes);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<WorkOrderTime>(
sql, parameters, nameof(GetWorkOrderTimesAsync), "SQL_GET_WORKORDER_TIMES", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesArchiveAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(JdeQueries.SqlGetWorkorderTimesArchive);
await foreach (var item in StreamQueryAsync<WorkOrderTime>(
sql, null, nameof(GetWorkOrderTimesArchiveAsync), "SQL_GET_WORKORDER_TIMES_ARCHIVE", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderRouting> GetWorkOrderRoutingsAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetWorkorderRoutingsFiltered
: JdeQueries.SqlGetWorkorderRoutings);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<WorkOrderRouting>(
sql, parameters, nameof(GetWorkOrderRoutingsAsync), "SQL_GET_WORKORDER_ROUTINGS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(
lastUpdateDt.HasValue
? JdeQueries.SqlGetWorkorderComponentsFiltered
: JdeQueries.SqlGetWorkorderComponents);
var parameters = lastUpdateDt.HasValue
? new { dateUpdated = lastUpdateDt.Value.ToJdeDate(), timeUpdated = lastUpdateDt.Value.ToJdeTime() }
: null;
await foreach (var item in StreamQueryAsync<WorkOrderComponent>(
sql, parameters, nameof(GetWorkOrderComponentsAsync), "SQL_GET_WORKORDER_COMPONENTS", ct))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsArchiveAsync(
DateTime? lastUpdateDt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var sql = ApplySchemaReplacements(JdeQueries.SqlGetWorkorderComponentsArchive);
await foreach (var item in StreamQueryAsync<WorkOrderComponent>(
sql, null, nameof(GetWorkOrderComponentsArchiveAsync), "SQL_GET_WORKORDER_COMPONENTS_ARCHIVE", ct))
{
yield return item;
}
}
}
@@ -0,0 +1,113 @@
using System.Runtime.CompilerServices;
using Dapper;
using JdeScoping.DataAccess.Configuration;
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Repository implementation for the JDE Oracle database.
/// </summary>
public partial class JdeRepository : IJdeRepository
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<JdeRepository> _logger;
private readonly IOptions<DataAccessOptions> _options;
private const string RepositoryName = "JdeRepository";
/// <summary>
/// Initializes a new instance of the <see cref="JdeRepository"/> class.
/// </summary>
public JdeRepository(
IDbConnectionFactory connectionFactory,
ILogger<JdeRepository> logger,
IOptions<DataAccessOptions> options)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
private string ApplySchemaReplacements(string sql)
{
return sql
.Replace("{ProductionSchema}", _options.Value.ProductionSchema)
.Replace("{ArchiveSchema}", _options.Value.ArchiveSchema)
.Replace("{StageSchema}", _options.Value.StageSchema);
}
private async IAsyncEnumerable<T> StreamQueryAsync<T>(
string sql,
object? parameters,
string operation,
string queryName,
[EnumeratorCancellation] CancellationToken ct,
int? timeoutSeconds = null)
{
OracleConnection? connection = null;
try
{
connection = await _connectionFactory.CreateJdeConnectionAsync(ct);
var timeout = timeoutSeconds ?? _options.Value.DefaultTimeoutSeconds;
// Use Query with buffered: false for streaming
var results = connection.Query<T>(
sql,
parameters,
commandTimeout: timeout,
buffered: false);
foreach (var item in results)
{
ct.ThrowIfCancellationRequested();
yield return item;
}
}
finally
{
if (connection != null)
{
await connection.DisposeAsync();
}
}
}
private async IAsyncEnumerable<T> StreamQueryFromStageAsync<T>(
string sql,
object? parameters,
string operation,
string queryName,
[EnumeratorCancellation] CancellationToken ct,
int? timeoutSeconds = null)
{
OracleConnection? connection = null;
try
{
connection = await _connectionFactory.CreateJdeStageConnectionAsync(ct);
var timeout = timeoutSeconds ?? _options.Value.DefaultTimeoutSeconds;
// Use Query with buffered: false for streaming
var results = connection.Query<T>(
sql,
parameters,
commandTimeout: timeout,
buffered: false);
foreach (var item in results)
{
ct.ThrowIfCancellationRequested();
yield return item;
}
}
finally
{
if (connection != null)
{
await connection.DisposeAsync();
}
}
}
}
@@ -0,0 +1,198 @@
using System.Data;
using Dapper;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.DataAccess.Queries;
using Microsoft.Data.SqlClient;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Data sync operations for LotFinder repository.
/// </summary>
public partial class LotFinderRepository
{
/// <inheritdoc/>
public async Task<List<DataUpdate>> GetLastDataUpdatesAsync(CancellationToken ct = default)
{
const string operation = nameof(GetLastDataUpdatesAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<DataUpdate>(
LotFinderQueries.SqlGetLastDataUpdates,
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_GET_LAST_DATA_UPDATES");
throw;
}
}
/// <inheritdoc/>
public async Task<TableSpec> GetTableSpecAsync(string tableName, CancellationToken ct = default)
{
const string operation = nameof(GetTableSpecAsync);
try
{
var tableSpec = new TableSpec(tableName);
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
// Load columns
var columns = await connection.QueryAsync<ColumnSpec>(
LotFinderQueries.SqlGetTableColumns,
new { name = tableName },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
tableSpec.Columns.AddRange(columns);
// Load primary key
var pkColumns = await connection.QueryAsync<string>(
LotFinderQueries.SqlGetTablePrimaryKey,
new { name = tableName },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
foreach (var columnName in pkColumns)
{
var column = tableSpec.GetColumn(columnName);
if (column != null)
{
tableSpec.PrimaryKey.Add(column);
}
}
return tableSpec;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_GET_TABLE_COLUMNS");
throw;
}
}
/// <inheritdoc/>
public async Task RebuildIndicesAsync(string tableName, CancellationToken ct = default)
{
const string operation = nameof(RebuildIndicesAsync);
// Validate table name against whitelist (SQL injection prevention)
if (!ValidTableNames.Contains(tableName))
{
throw new ArgumentException($"Invalid table name: {tableName}", nameof(tableName));
}
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var sql = $"ALTER INDEX ALL ON [{tableName}] REBUILD WITH (FILLFACTOR = 95)";
await connection.ExecuteAsync(sql, commandTimeout: _options.Value.RebuildIndexTimeoutSeconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_REBUILD_INDICES");
throw;
}
}
/// <inheritdoc/>
public async Task PostProcessMisDataAsync(CancellationToken ct = default)
{
const string operation = nameof(PostProcessMisDataAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
await connection.ExecuteAsync(
LotFinderQueries.SqlPostprocessMisData,
commandTimeout: _options.Value.DefaultTimeoutSeconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_POSTPROCESS_MISDATA");
throw;
}
}
/// <inheritdoc/>
public async Task<int> BulkInsertAsync<T>(string tableName, IEnumerable<T> records, CancellationToken ct = default)
{
const string operation = nameof(BulkInsertAsync);
// Validate table name against whitelist
if (!ValidTableNames.Contains(tableName))
{
throw new ArgumentException($"Invalid table name: {tableName}", nameof(tableName));
}
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
// Use SqlBulkCopy for efficient bulk insert
using var bulkCopy = new SqlBulkCopy(connection)
{
DestinationTableName = $"dbo.[{tableName}]",
BulkCopyTimeout = _options.Value.DefaultTimeoutSeconds
};
// Convert records to DataTable
var recordList = records.ToList();
var dataTable = ToDataTable(recordList);
await bulkCopy.WriteToServerAsync(dataTable, ct);
return recordList.Count;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "BulkInsert");
throw;
}
}
/// <inheritdoc/>
public async Task TruncateTableAsync(string tableName, CancellationToken ct = default)
{
const string operation = nameof(TruncateTableAsync);
// Validate table name against whitelist
if (!ValidTableNames.Contains(tableName))
{
throw new ArgumentException($"Invalid table name: {tableName}", nameof(tableName));
}
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var sql = $"TRUNCATE TABLE dbo.[{tableName}]";
await connection.ExecuteAsync(sql, commandTimeout: _options.Value.DefaultTimeoutSeconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "TruncateTable");
throw;
}
}
}
@@ -0,0 +1,299 @@
using System.Data;
using Dapper;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.Core.ViewModels;
using JdeScoping.DataAccess.Queries;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Reference data lookup operations for LotFinder repository.
/// </summary>
public partial class LotFinderRepository
{
/// <inheritdoc/>
public async Task<List<Item>> SearchItemsAsync(string filter, CancellationToken ct = default)
{
const string operation = nameof(SearchItemsAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<Item>(
LotFinderQueries.SqlSearchItems,
new { filter },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_SEARCH_ITEMS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<Item>> LookupItemsAsync(List<string> itemNumbers, CancellationToken ct = default)
{
const string operation = nameof(LookupItemsAsync);
try
{
var dataTable = new DataTable();
dataTable.Columns.Add("ItemNumber", typeof(string));
foreach (var itemNumber in itemNumbers)
{
dataTable.Rows.Add(itemNumber);
}
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<Item>(
LotFinderQueries.SqlLookupItems,
new { itemNumbers = dataTable.AsTableValuedParameter("ItemNumberFilterParameter") },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_LOOKUP_ITEMS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<WorkOrder>> LookupWorkordersAsync(List<long> workorderNumbers, CancellationToken ct = default)
{
const string operation = nameof(LookupWorkordersAsync);
try
{
var dataTable = new DataTable();
dataTable.Columns.Add("WorkOrderNumber", typeof(long));
foreach (var workOrderNumber in workorderNumbers)
{
dataTable.Rows.Add(workOrderNumber);
}
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<WorkOrder>(
LotFinderQueries.SqlLookupWorkorders,
new { workOrderNumbers = dataTable.AsTableValuedParameter("WorkOrderFilterParameter") },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_LOOKUP_WORKORDERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<WorkCenter>> SearchWorkCentersAsync(string filter, CancellationToken ct = default)
{
const string operation = nameof(SearchWorkCentersAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<WorkCenter>(
LotFinderQueries.SqlSearchWorkCenters,
new { filter },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_SEARCH_WORK_CENTERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<WorkCenter>> LookupWorkCentersAsync(List<string> codes, CancellationToken ct = default)
{
const string operation = nameof(LookupWorkCentersAsync);
try
{
var dataTable = new DataTable();
dataTable.Columns.Add("Code", typeof(string));
foreach (var code in codes)
{
dataTable.Rows.Add(code);
}
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<WorkCenter>(
LotFinderQueries.SqlLookupWorkCenters,
new { workCenterCodes = dataTable.AsTableValuedParameter("WorkCenterFilterParameter") },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_LOOKUP_WORK_CENTERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<ProfitCenter>> SearchProfitCentersAsync(string filter, CancellationToken ct = default)
{
const string operation = nameof(SearchProfitCentersAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<ProfitCenter>(
LotFinderQueries.SqlSearchProfitCenters,
new { filter },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_SEARCH_PROFIT_CENTERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<ProfitCenter>> LookupProfitCentersAsync(List<string> codes, CancellationToken ct = default)
{
const string operation = nameof(LookupProfitCentersAsync);
try
{
var dataTable = new DataTable();
dataTable.Columns.Add("Code", typeof(string));
foreach (var code in codes)
{
dataTable.Rows.Add(code);
}
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<ProfitCenter>(
LotFinderQueries.SqlLookupProfitCenters,
new { profitCenterCodes = dataTable.AsTableValuedParameter("ProfitCenterFilterParameter") },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_LOOKUP_PROFIT_CENTERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<JdeUser>> SearchUsersAsync(string filter, CancellationToken ct = default)
{
const string operation = nameof(SearchUsersAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<JdeUser>(
LotFinderQueries.SqlSearchUsers,
new { filter },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_SEARCH_USERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<JdeUser>> LookupUsersAsync(List<string> userIds, CancellationToken ct = default)
{
const string operation = nameof(LookupUsersAsync);
try
{
var dataTable = new DataTable();
dataTable.Columns.Add("UserName", typeof(string));
foreach (var userId in userIds)
{
dataTable.Rows.Add(userId);
}
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<JdeUser>(
LotFinderQueries.SqlLookupUsers,
new { userIDs = dataTable.AsTableValuedParameter("OperatorFilterParameter") },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_LOOKUP_USERS");
throw;
}
}
/// <inheritdoc/>
public async Task<List<Lot>> LookupLotsAsync(List<LotViewModel> lots, CancellationToken ct = default)
{
const string operation = nameof(LookupLotsAsync);
try
{
var dataTable = new DataTable();
dataTable.Columns.Add("ComponentLotNumber", typeof(string));
dataTable.Columns.Add("ItemNumber", typeof(string));
foreach (var lot in lots)
{
dataTable.Rows.Add(lot.LotNumber, lot.ItemNumber);
}
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<Lot>(
LotFinderQueries.SqlLookupLots,
new { lotNumbers = dataTable.AsTableValuedParameter("ComponentLotFilterParameter") },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_LOOKUP_LOTS");
throw;
}
}
}
@@ -0,0 +1,200 @@
using System.Data;
using Dapper;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.DataAccess.Queries;
using Microsoft.Data.SqlClient;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Search management operations for LotFinder repository.
/// </summary>
public partial class LotFinderRepository
{
/// <inheritdoc/>
public async Task<List<Search>> GetUserSearchesAsync(string userName, CancellationToken ct = default)
{
const string operation = nameof(GetUserSearchesAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<Search>(
LotFinderQueries.SqlGetUserSearches,
new { userName },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_GET_USER_SEARCHES");
throw; // Unreachable but satisfies compiler
}
}
/// <inheritdoc/>
public async Task<List<Search>> GetQueuedSearchesAsync(CancellationToken ct = default)
{
const string operation = nameof(GetQueuedSearchesAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryAsync<Search>(
LotFinderQueries.SqlGetQueuedSearches,
commandTimeout: _options.Value.DefaultTimeoutSeconds);
return result.ToList();
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_GET_QUEUED_SEARCHES");
throw;
}
}
/// <inheritdoc/>
public async Task<Search?> GetSearchAsync(int id, CancellationToken ct = default)
{
const string operation = nameof(GetSearchAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
var result = await connection.QueryFirstOrDefaultAsync<Search>(
LotFinderQueries.SqlGetSearch,
new { id },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
if (result != null)
{
result.Id = id;
}
return result;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_GET_SEARCH");
throw;
}
}
/// <inheritdoc/>
public async Task<byte[]?> GetSearchResultsAsync(int id, CancellationToken ct = default)
{
const string operation = nameof(GetSearchResultsAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
return await connection.QueryFirstOrDefaultAsync<byte[]>(
LotFinderQueries.SqlGetSearchResults,
new { id },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_GET_SEARCH_RESULTS");
throw;
}
}
/// <inheritdoc/>
public async Task<int> SubmitSearchAsync(Search search, CancellationToken ct = default)
{
const string operation = nameof(SubmitSearchAsync);
try
{
search.Status = SearchStatus.Queued;
search.SubmitDt = DateTime.UtcNow;
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
await using var command = new SqlCommand("SubmitSearch", connection)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = _options.Value.DefaultTimeoutSeconds
};
command.Parameters.AddWithValue("p_UserName", search.UserName);
command.Parameters.AddWithValue("p_Name", search.Name);
command.Parameters.AddWithValue("p_Criteria", search.CriteriaJson);
var searchIdParam = new SqlParameter("o_SearchID", SqlDbType.Int)
{
Direction = ParameterDirection.Output
};
command.Parameters.Add(searchIdParam);
await command.ExecuteNonQueryAsync(ct);
return Convert.ToInt32(searchIdParam.Value);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SubmitSearch");
throw;
}
}
/// <inheritdoc/>
public async Task UpdateSearchStatusAsync(int id, SearchStatus status, CancellationToken ct = default)
{
const string operation = nameof(UpdateSearchStatusAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
await connection.ExecuteAsync(
LotFinderQueries.SqlUpdateSearchStatus,
new { id, status = (int)status },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_UPDATE_SEARCH_STATUS");
throw;
}
}
/// <inheritdoc/>
public async Task UpdateSearchResultsAsync(int id, byte[] results, CancellationToken ct = default)
{
const string operation = nameof(UpdateSearchResultsAsync);
try
{
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
await connection.ExecuteAsync(
LotFinderQueries.SqlUpdateSearchResults,
new { id, results },
commandTimeout: _options.Value.DefaultTimeoutSeconds);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogAndThrow(ex, operation, "SQL_UPDATE_SEARCH_RESULTS");
throw;
}
}
}
@@ -0,0 +1,123 @@
using System.Data;
using JdeScoping.Core.Interfaces;
using JdeScoping.DataAccess.Configuration;
using JdeScoping.DataAccess.Exceptions;
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace JdeScoping.DataAccess.Repositories;
/// <summary>
/// Repository implementation for the LotFinder SQL Server cache database.
/// </summary>
public partial class LotFinderRepository : ILotFinderRepository
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<LotFinderRepository> _logger;
private readonly IOptions<DataAccessOptions> _options;
private const string RepositoryName = "LotFinderRepository";
/// <summary>
/// Valid table names for index rebuild operations (SQL injection whitelist).
/// </summary>
private static readonly HashSet<string> ValidTableNames = new(StringComparer.OrdinalIgnoreCase)
{
"Branch", "DataUpdate", "FunctionCode", "Item", "JdeUser",
"Lot", "LotLocation", "LotUsage_Curr", "LotUsage_Hist",
"MisData", "OrgHierarchy", "ProfitCenter", "RouteMaster",
"Search", "StatusCode", "WorkCenter",
"WorkOrder_Curr", "WorkOrder_Hist",
"WorkOrderComponent_Curr", "WorkOrderComponent_Hist",
"WorkOrderRouting",
"WorkOrderStep_Curr", "WorkOrderStep_Hist",
"WorkOrderTime_Curr", "WorkOrderTime_Hist"
};
/// <summary>
/// Initializes a new instance of the <see cref="LotFinderRepository"/> class.
/// </summary>
public LotFinderRepository(
IDbConnectionFactory connectionFactory,
ILogger<LotFinderRepository> logger,
IOptions<DataAccessOptions> options)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
private void LogAndThrow(Exception ex, string operation, string queryName)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["Repository"] = RepositoryName,
["Operation"] = operation,
["QueryName"] = queryName
}))
{
_logger.LogError(ex, "Query {QueryName} failed in {Operation}", queryName, operation);
}
if (ex is SqlException sqlEx && IsTimeoutError(sqlEx))
{
throw new DataAccessTimeoutException(
$"Query {queryName} timed out after {_options.Value.DefaultTimeoutSeconds} seconds.",
_options.Value.DefaultTimeoutSeconds,
operation,
RepositoryName,
ex);
}
throw new QueryException(
$"Query {queryName} failed: {ex.Message}",
queryName,
operation,
RepositoryName,
ex);
}
private static bool IsTimeoutError(SqlException ex)
{
// SQL Server timeout error number: -2
return ex.Number == -2;
}
private static DataTable ToDataTable<T>(List<T> items)
{
var dataTable = new DataTable();
var properties = typeof(T).GetProperties()
.Where(p => p.CanRead && IsSupportedType(p.PropertyType))
.ToArray();
foreach (var prop in properties)
{
var type = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
dataTable.Columns.Add(prop.Name, type);
}
foreach (var item in items)
{
var row = dataTable.NewRow();
foreach (var prop in properties)
{
var value = prop.GetValue(item);
row[prop.Name] = value ?? DBNull.Value;
}
dataTable.Rows.Add(row);
}
return dataTable;
}
private static bool IsSupportedType(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
return underlyingType.IsPrimitive
|| underlyingType == typeof(string)
|| underlyingType == typeof(DateTime)
|| underlyingType == typeof(decimal)
|| underlyingType == typeof(Guid);
}
}
@@ -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);
}
}