refactor(data-access): remove TVP code and simplify SearchModel

- Remove all List<*FilterEntry> properties and *FilterEnabled computed properties from SearchModel
- Delete TableValuedParameterExtensions.cs
- Delete entire FilterEntries folder and all filter entry model classes
- Delete FilterHandlers folder and all filter handler classes
- Delete IFilterHandler interface and FilterResult model
- Update MisQueryBuilder to use SQL extraction functions instead of model properties
- Update SearchProcessor to get ExtractMisData from database using fn_GetSearchExtractMisData
- Update DependencyInjection to remove filter handler registrations
- Delete obsolete tests for TVP extensions and filter handlers

Filter criteria are now stored as JSON in Search.Criteria column and extracted using SQL functions (fn_GetSearch*) during query execution.
This commit is contained in:
Joseph Doherty
2026-01-06 14:32:03 -05:00
parent a2a8bb3e9f
commit 691a6d1ffd
26 changed files with 37 additions and 1910 deletions
@@ -1,7 +1,6 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Options;
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataAccess.QueryBuilders;
using JdeScoping.DataAccess.Repositories;
@@ -45,17 +44,9 @@ public static class DataAccessDependencyInjection
// 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)
// Note: Filter criteria are extracted from database JSON using SQL functions,
// eliminating the need for filter handler classes.
services.AddScoped<ISearchQueryBuilder, SqlKataSearchQueryBuilder>();
// Register search processing services (scoped)
@@ -1,156 +0,0 @@
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");
}
}
@@ -1,98 +0,0 @@
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);
}
}
@@ -1,42 +0,0 @@
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>());
}
@@ -1,46 +0,0 @@
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);
}
}
@@ -1,55 +0,0 @@
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);
}
}
@@ -1,55 +0,0 @@
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);
}
}
@@ -1,62 +0,0 @@
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);
}
}
@@ -1,38 +0,0 @@
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);
}
}
@@ -1,64 +0,0 @@
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);
}
}
@@ -1,75 +0,0 @@
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);
}
}
@@ -1,30 +0,0 @@
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; }
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,34 +0,0 @@
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;
}
@@ -1,27 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,10 +0,0 @@
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);
@@ -1,10 +1,11 @@
using JdeScoping.DataAccess.Models.FilterEntries;
using JdeScoping.DataAccess.Models.Results;
namespace JdeScoping.DataAccess.Models;
/// <summary>
/// Reporting search data model.
/// Filter criteria are stored as JSON in the Search.Criteria column
/// and are extracted using SQL functions (fn_GetSearch*) during query execution.
/// </summary>
public class SearchModel
{
@@ -38,96 +39,6 @@ public class SearchModel
/// </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>
@@ -73,12 +73,18 @@ public sealed class MisQueryBuilder
""";
}
private string BuildMisCteSql(SearchModel model)
private static string BuildMisCteSql()
{
var joins = BuildMisJoins(model);
var whereClause = BuildMisWhereClause(model);
// The SQL uses temp tables (#P_ItemNumbers, #P_WorkCenters, #P_OperatorIDs)
// and variables (@MinDt, @MaxDt) that were already populated by the main search query builder.
// This query uses dynamic join/filter conditions based on what filters exist.
return """
-- Build MIS extraction using existing temp tables and date variables
DECLARE @HasMisItemFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_ItemNumbers) THEN 1 ELSE 0 END;
DECLARE @HasMisWorkCenterFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_WorkCenters) THEN 1 ELSE 0 END;
DECLARE @HasMisOperatorFilter BIT = CASE WHEN EXISTS(SELECT 1 FROM #P_OperatorIDs) THEN 1 ELSE 0 END;
DECLARE @HasMisTimespanFilter BIT = CASE WHEN @MinDt IS NOT NULL OR @MaxDt IS NOT NULL THEN 1 ELSE 0 END;
return $"""
WITH MIS_CTE AS(
SELECT DISTINCT wo.WorkOrderNumber,
wo.ItemNumber,
@@ -90,11 +96,20 @@ public sealed class MisQueryBuilder
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}
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)
LEFT OUTER JOIN #P_ItemNumbers p_in ON (@HasMisItemFilter = 0 OR wo.ItemNumber = p_in.ItemNumber)
LEFT OUTER JOIN #P_WorkCenters p_wc ON (@HasMisWorkCenterFilter = 0 OR wos.WorkCenterCode = p_wc.Code)
LEFT OUTER JOIN #P_OperatorIDs p_oi ON (@HasMisOperatorFilter = 0 OR wot.AddressNumber = p_oi.AddressNumber)
WHERE (@HasMisItemFilter = 0 OR p_in.ItemNumber IS NOT NULL)
AND (@HasMisWorkCenterFilter = 0 OR p_wc.Code IS NOT NULL)
AND (@HasMisOperatorFilter = 0 OR p_oi.AddressNumber IS NOT NULL)
AND (@HasMisTimespanFilter = 0 OR
((@MinDt IS NULL OR wos.EndDT >= @MinDt OR wot.GlDate >= @MinDt) AND
(@MaxDt IS NULL OR wos.EndDT <= @MaxDt OR wot.GlDate <= @MaxDt)))
)
INSERT INTO #TempMISData
INSERT INTO #TempMisData
(
WorkOrderNumber,
ItemNumber,
@@ -149,56 +164,4 @@ public sealed class MisQueryBuilder
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 "";
}
}
@@ -145,8 +145,13 @@ public sealed class SearchProcessor
model.Results = results.ToList();
_logger.LogInformation("Search {SearchId} returned {ResultCount} results", model.Id, model.Results.Count);
// Extract MIS data if requested
if (model.ExtractMisData)
// Extract MIS data if requested (check ExtractMisData from database using extraction function)
var extractMisData = await connection.QuerySingleOrDefaultAsync<bool?>(
"SELECT dbo.fn_GetSearchExtractMisData(@SearchId)",
new { SearchId = model.Id },
commandTimeout: _options.QueryTimeoutSeconds) ?? false;
if (extractMisData)
{
await ExecuteMisExtractionAsync(model, connection, ct);
}
@@ -161,24 +166,14 @@ public sealed class SearchProcessor
{
_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;
}
// Build and execute MIS setup SQL (uses temp tables and variables from main query)
var misSetupStatements = _misQueryBuilder.BuildMisExtractionSql(model.Id);
foreach (var sql in misSetupStatements)
{
await connection.ExecuteAsync(
sql,
misParameters,
new { SearchId = model.Id },
commandTimeout: _options.QueryTimeoutSeconds);
}
@@ -1,480 +0,0 @@
using System.Data;
using System.Reflection;
using Dapper;
using JdeScoping.DataAccess.Extensions;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using Shouldly;
using Xunit;
namespace JdeScoping.DataAccess.Tests.Extensions;
/// <summary>
/// Unit tests for TableValuedParameterExtensions.
/// </summary>
public sealed class TableValuedParameterExtensionsTests
{
#region CreateWorkOrderFilterParameter Tests
[Fact]
public void CreateWorkOrderFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC" }
]
};
// Act
var param = model.CreateWorkOrderFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.ShouldNotBeNull();
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("WorkOrderNumber").ShouldBeTrue();
dataTable.Columns["WorkOrderNumber"]!.DataType.ShouldBe(typeof(long));
}
[Fact]
public void CreateWorkOrderFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter = []
};
// Act
var param = model.CreateWorkOrderFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.ShouldNotBeNull();
dataTable.Rows.Count.ShouldBe(0);
}
[Fact]
public void CreateWorkOrderFilterParameter_PopulatesCorrectData()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345 },
new WorkOrderFilterEntry { WorkOrderNumber = 67890 }
]
};
// Act
var param = model.CreateWorkOrderFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(2);
dataTable.Rows[0]["WorkOrderNumber"].ShouldBe(12345L);
dataTable.Rows[1]["WorkOrderNumber"].ShouldBe(67890L);
}
#endregion
#region CreateItemNumberFilterParameter Tests
[Fact]
public void CreateItemNumberFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ItemNumberFilter =
[
new ItemNumberFilterEntry { ItemNumber = "ABC123" }
]
};
// Act
var param = model.CreateItemNumberFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.ShouldNotBeNull();
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("ItemNumber").ShouldBeTrue();
dataTable.Columns["ItemNumber"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateItemNumberFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ItemNumberFilter = []
};
// Act
var param = model.CreateItemNumberFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateProfitCenterFilterParameter Tests
[Fact]
public void CreateProfitCenterFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ProfitCenterFilter =
[
new ProfitCenterFilterEntry { Code = "PC001" }
]
};
// Act
var param = model.CreateProfitCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("Code").ShouldBeTrue();
dataTable.Columns["Code"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateProfitCenterFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ProfitCenterFilter = []
};
// Act
var param = model.CreateProfitCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateWorkCenterFilterParameter Tests
[Fact]
public void CreateWorkCenterFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
WorkCenterFilter =
[
new WorkCenterFilterEntry { Code = "WC001" }
]
};
// Act
var param = model.CreateWorkCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("Code").ShouldBeTrue();
dataTable.Columns["Code"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateWorkCenterFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
WorkCenterFilter = []
};
// Act
var param = model.CreateWorkCenterFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateComponentLotFilterParameter Tests
[Fact]
public void CreateComponentLotFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var param = model.CreateComponentLotFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(2);
dataTable.Columns.Contains("ComponentLotNumber").ShouldBeTrue();
dataTable.Columns.Contains("ItemNumber").ShouldBeTrue();
dataTable.Columns["ComponentLotNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["ItemNumber"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateComponentLotFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter = []
};
// Act
var param = model.CreateComponentLotFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
[Fact]
public void CreateComponentLotFilterParameter_PopulatesCorrectData()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" },
new ComponentLotFilterEntry { LotNumber = "LOT002", ItemNumber = "ITEM002" }
]
};
// Act
var param = model.CreateComponentLotFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(2);
dataTable.Rows[0]["ComponentLotNumber"].ShouldBe("LOT001");
dataTable.Rows[0]["ItemNumber"].ShouldBe("ITEM001");
}
#endregion
#region CreateOperatorFilterParameter Tests
[Fact]
public void CreateOperatorFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
OperatorFilter =
[
new OperatorFilterEntry { UserId = "USER01", AddressNumber = 123 }
]
};
// Act
var param = model.CreateOperatorFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(1);
dataTable.Columns.Contains("UserName").ShouldBeTrue();
dataTable.Columns["UserName"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateOperatorFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
OperatorFilter = []
};
// Act
var param = model.CreateOperatorFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
#endregion
#region CreateItemOperationMisFilterParameter Tests
[Fact]
public void CreateItemOperationMisFilterParameter_ProducesCorrectSchema()
{
// Arrange
var model = new SearchModel
{
ItemOperationMisFilter =
[
new ItemOperationMisFilterEntry
{
ItemNumber = "ITEM001",
OperationNumber = "010",
MisNumber = "MIS001",
MisRevision = "A"
}
]
};
// Act
var param = model.CreateItemOperationMisFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Columns.Count.ShouldBe(4);
dataTable.Columns.Contains("ItemNumber").ShouldBeTrue();
dataTable.Columns.Contains("OperationNumber").ShouldBeTrue();
dataTable.Columns.Contains("MisNumber").ShouldBeTrue();
dataTable.Columns.Contains("MisRevision").ShouldBeTrue();
dataTable.Columns["ItemNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["OperationNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["MisNumber"]!.DataType.ShouldBe(typeof(string));
dataTable.Columns["MisRevision"]!.DataType.ShouldBe(typeof(string));
}
[Fact]
public void CreateItemOperationMisFilterParameter_WithEmptyCollection_ProducesEmptyDataTable()
{
// Arrange
var model = new SearchModel
{
ItemOperationMisFilter = []
};
// Act
var param = model.CreateItemOperationMisFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(0);
}
[Fact]
public void CreateItemOperationMisFilterParameter_PopulatesCorrectData()
{
// Arrange
var model = new SearchModel
{
ItemOperationMisFilter =
[
new ItemOperationMisFilterEntry
{
ItemNumber = "ITEM001",
OperationNumber = "010",
MisNumber = "MIS001",
MisRevision = "A"
}
]
};
// Act
var param = model.CreateItemOperationMisFilterParameter();
var dataTable = ExtractDataTable(param);
// Assert
dataTable.Rows.Count.ShouldBe(1);
dataTable.Rows[0]["ItemNumber"].ShouldBe("ITEM001");
dataTable.Rows[0]["OperationNumber"].ShouldBe("010");
dataTable.Rows[0]["MisNumber"].ShouldBe("MIS001");
dataTable.Rows[0]["MisRevision"].ShouldBe("A");
}
#endregion
#region Helper Methods
/// <summary>
/// Extracts the underlying DataTable from a Dapper table-valued parameter.
/// Uses reflection to access internal fields across different Dapper versions.
/// </summary>
private static DataTable ExtractDataTable(SqlMapper.ICustomQueryParameter param)
{
// The TableValuedParameter wraps a DataTable - try multiple field/property names
// across different Dapper versions
var type = param.GetType();
var bindingFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
// Try field names used in different Dapper versions
var fieldNames = new[] { "_table", "table", "Table", "_dataTable", "dataTable" };
foreach (var fieldName in fieldNames)
{
var field = type.GetField(fieldName, bindingFlags);
if (field != null && field.FieldType == typeof(DataTable))
{
var value = field.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
// Try property names
var propertyNames = new[] { "Table", "DataTable", "table", "_table" };
foreach (var propName in propertyNames)
{
var prop = type.GetProperty(propName, bindingFlags);
if (prop != null && prop.PropertyType == typeof(DataTable))
{
var value = prop.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
// Last resort: scan all fields
foreach (var field in type.GetFields(bindingFlags))
{
if (field.FieldType == typeof(DataTable))
{
var value = field.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
// Scan all properties
foreach (var prop in type.GetProperties(bindingFlags))
{
if (prop.PropertyType == typeof(DataTable))
{
var value = prop.GetValue(param);
if (value is DataTable dt)
return dt;
}
}
throw new InvalidOperationException(
$"Could not extract DataTable from {type.FullName}. " +
$"Fields: {string.Join(", ", type.GetFields(bindingFlags).Select(f => f.Name))}. " +
$"Properties: {string.Join(", ", type.GetProperties(bindingFlags).Select(p => p.Name))}");
}
#endregion
}
@@ -1,196 +0,0 @@
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using Shouldly;
using SqlKata.Compilers;
using Xunit;
namespace JdeScoping.DataAccess.Tests.FilterHandlers;
/// <summary>
/// Unit tests for ComponentLotFilterHandler.
/// </summary>
public sealed class ComponentLotFilterHandlerTests
{
private readonly SqlServerCompiler _compiler = new();
private readonly ComponentLotFilterHandler _handler = new();
[Fact]
public void IsEnabled_WithComponentLotFilters_ReturnsTrue()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsEnabled_WithEmptyComponentLotFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter = []
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsEnabled_WithNullComponentLotFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel();
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void Apply_GeneratedSql_ContainsWorkOrderComponentJoin()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.ShouldNotBeNull();
result.SetupSql.ShouldNotBeEmpty();
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("dbo.WorkOrderComponent AS woc");
}
[Fact]
public void Apply_GeneratedSql_ContainsLotUsageJoin()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("dbo.LotUsage AS lu");
}
[Fact]
public void Apply_GeneratedSql_SetsCARDEXFlag()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
// CARDEX flag is set (not PartsList) per the ComponentLotFilterHandler implementation
allSql.ShouldContain("TARGET.CARDEX = 1");
}
[Fact]
public void Apply_GeneratedSql_DoesNotSetPartsListFlag()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
// ComponentLotFilterHandler sets CARDEX, not PartsList
allSql.ShouldNotContain("PartsList = 1");
}
[Fact]
public void Apply_GeneratedSql_ContainsSplitOrderLogic()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("SplitOrder");
}
[Fact]
public void Apply_Parameters_ContainsComponentLotFilterParameter()
{
// Arrange
var model = new SearchModel
{
ComponentLotFilter =
[
new ComponentLotFilterEntry { LotNumber = "LOT001", ItemNumber = "ITEM001" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.Parameters.ShouldContainKey("p_ComponentLotFilter");
}
[Fact]
public void Priority_ReturnsExpectedValue()
{
// Assert
_handler.Priority.ShouldBe(30);
}
}
@@ -1,155 +0,0 @@
using JdeScoping.DataAccess.FilterHandlers;
using JdeScoping.DataAccess.Models;
using JdeScoping.DataAccess.Models.FilterEntries;
using Shouldly;
using SqlKata.Compilers;
using Xunit;
namespace JdeScoping.DataAccess.Tests.FilterHandlers;
/// <summary>
/// Unit tests for WorkOrderFilterHandler.
/// </summary>
public sealed class WorkOrderFilterHandlerTests
{
private readonly SqlServerCompiler _compiler = new();
private readonly WorkOrderFilterHandler _handler = new();
[Fact]
public void IsEnabled_WithWorkOrderFilters_ReturnsTrue()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeTrue();
}
[Fact]
public void IsEnabled_WithEmptyWorkOrderFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter = []
};
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void IsEnabled_WithNullWorkOrderFilters_ReturnsFalse()
{
// Arrange
var model = new SearchModel();
// Act
var result = _handler.IsEnabled(model);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void Apply_GeneratedSql_ContainsMerge()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.ShouldNotBeNull();
result.SetupSql.ShouldNotBeEmpty();
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("MERGE #Temp_WO AS TARGET");
}
[Fact]
public void Apply_GeneratedSql_ContainsManuallySpecified()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("ManuallySpecified = 1");
}
[Fact]
public void Apply_GeneratedSql_ContainsSplitOrderLogic()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
var allSql = string.Join("\n", result.SetupSql);
allSql.ShouldContain("SplitOrder");
allSql.ShouldContain("ParentWorkOrderNumber");
}
[Fact]
public void Apply_Parameters_ContainsWorkOrderFilterParameter()
{
// Arrange
var model = new SearchModel
{
WorkOrderFilter =
[
new WorkOrderFilterEntry { WorkOrderNumber = 12345, ItemNumber = "ABC123" }
]
};
// Act
var result = _handler.Apply(model, _compiler);
// Assert
result.Parameters.ShouldContainKey("p_WorkOrderFilter");
}
[Fact]
public void Priority_ReturnsExpectedValue()
{
// Assert
_handler.Priority.ShouldBe(10);
}
}