Files
jdescopingtool/openspec/specs/data-access/spec.md
T

2121 lines
73 KiB
Markdown

# Data Access Specification
## Purpose
The data access layer provides interface-based repositories with dependency injection for accessing three distinct data sources in the JDE Scoping Tool application:
1. **JDE Oracle** - Primary enterprise system (JD Edwards) for manufacturing data
2. **CMS (Oracle)** - Manufacturing Information System (MIS) data (legacy used DDTek.Oracle driver, migrating to Oracle.ManagedDataAccess.Core)
3. **SQL Server Cache (LotFinderDB)** - Local cache database for search operations
The layer follows a modern repository pattern with:
- **Interface-based repositories** (`ILotFinderRepository`, `IJdeRepository`, `ICmsRepository`) for testability and loose coupling
- **Connection factory abstraction** (`IDbConnectionFactory`) for database connections
- **Dependency injection** via extension methods for service registration
- **Async-first design** with `IAsyncEnumerable<T>` for streaming and `CancellationToken` support
- **Typed exception hierarchy** for consistent error handling
## Source Reference
| Legacy Files | Purpose |
|--------------|---------|
| OLD/DataModel/Process/LotFinderDB.cs | SQL Server cache - base class with connection management |
| OLD/DataModel/Process/LotFinderDB.SearchManagement.cs | SQL Server cache - search CRUD operations |
| OLD/DataModel/Process/LotFinderDB.Item.cs | SQL Server cache - item search/lookup |
| OLD/DataModel/Process/LotFinderDB.WorkOrder.cs | SQL Server cache - work order lookup |
| OLD/DataModel/Process/LotFinderDB.WorkCenter.cs | SQL Server cache - work center search/lookup |
| OLD/DataModel/Process/LotFinderDB.Lot.cs | SQL Server cache - lot lookup |
| OLD/DataModel/Process/LotFinderDB.ProfitCenter.cs | SQL Server cache - profit center search/lookup |
| OLD/DataModel/Process/LotFinderDB.User.cs | SQL Server cache - user search/lookup |
| OLD/DataModel/Process/LotFinderDB.MisData.cs | SQL Server cache - MIS data post-processing |
| OLD/DataModel/Process/JDE.cs | JDE Oracle - base class with connection management |
| OLD/DataModel/Process/JDE.WorkOrders.cs | JDE Oracle - work order queries |
| OLD/DataModel/Process/JDE.WorkOrderStep.cs | JDE Oracle - work order step queries |
| OLD/DataModel/Process/JDE.WorkOrderTime.cs | JDE Oracle - work order time transaction queries |
| OLD/DataModel/Process/JDE.WorkOrderRouting.cs | JDE Oracle - work order routing transaction queries |
| OLD/DataModel/Process/JDE.WorkOrderComponent.cs | JDE Oracle - work order component queries |
| OLD/DataModel/Process/JDE.Lots.cs | JDE Oracle - lot queries |
| OLD/DataModel/Process/JDE.LotUsage.cs | JDE Oracle - lot usage (cardex) queries |
| OLD/DataModel/Process/JDE.LotLocation.cs | JDE Oracle - lot location queries |
| OLD/DataModel/Process/JDE.Items.cs | JDE Oracle - item queries |
| OLD/DataModel/Process/JDE.Users.cs | JDE Oracle - user queries |
| OLD/DataModel/Process/JDE.BusinessUnits.cs | JDE Oracle - branch/profit center/work center queries |
| OLD/DataModel/Process/JDE.StatusCodes.cs | JDE Oracle - status code queries |
| OLD/DataModel/Process/JDE.FunctionCode.cs | JDE Oracle - function code queries |
| OLD/DataModel/Process/JDE.OrgHierarchy.cs | JDE Oracle - organization hierarchy queries |
| OLD/DataModel/Process/JDE.RouteMaster.cs | JDE Oracle - route master queries |
| OLD/DataModel/Process/CMS.cs | CMS - base class with connection management |
| OLD/DataModel/Process/CMS.MisData.cs | CMS - MIS data queries |
| OLD/DataModel/Process/QueryRepository.cs | External SQL query file management |
| OLD/DataModel/Config.cs | Connection string configuration |
---
## Requirements
### Requirement: Repository Interface Pattern
The system SHALL define repository interfaces for all data access operations.
#### Interface Definitions
```csharp
public interface ILotFinderRepository
{
// Search Management
Task<List<Search>> GetUserSearchesAsync(string userName, CancellationToken ct = default);
Task<List<Search>> GetQueuedSearchesAsync(CancellationToken ct = default);
Task<Search?> GetSearchAsync(int id, CancellationToken ct = default);
Task<byte[]?> GetSearchResultsAsync(int id, CancellationToken ct = default);
Task<int> SubmitSearchAsync(Search search, CancellationToken ct = default);
Task UpdateSearchStatusAsync(int id, SearchStatus status, CancellationToken ct = default);
Task UpdateSearchResultsAsync(int id, byte[] results, CancellationToken ct = default);
// Reference Data Lookup
Task<List<Item>> SearchItemsAsync(string filter, CancellationToken ct = default);
Task<List<Item>> LookupItemsAsync(List<string> itemNumbers, CancellationToken ct = default);
Task<List<WorkOrder>> LookupWorkordersAsync(List<long> workorderNumbers, CancellationToken ct = default);
Task<List<WorkCenter>> SearchWorkCentersAsync(string filter, CancellationToken ct = default);
Task<List<WorkCenter>> LookupWorkCentersAsync(List<string> codes, CancellationToken ct = default);
Task<List<ProfitCenter>> SearchProfitCentersAsync(string filter, CancellationToken ct = default);
Task<List<ProfitCenter>> LookupProfitCentersAsync(List<string> codes, CancellationToken ct = default);
Task<List<JdeUser>> SearchUsersAsync(string filter, CancellationToken ct = default);
Task<List<JdeUser>> LookupUsersAsync(List<string> userIds, CancellationToken ct = default);
Task<List<Lot>> LookupLotsAsync(List<LotViewModel> lots, CancellationToken ct = default);
// Data Sync Operations
Task<List<DataUpdate>> GetLastDataUpdatesAsync(CancellationToken ct = default);
Task<TableSpec> GetTableSpecAsync(string tableName, CancellationToken ct = default);
Task RebuildIndicesAsync(string tableName, CancellationToken ct = default);
Task PostProcessMisDataAsync(CancellationToken ct = default);
Task<int> BulkInsertAsync<T>(string tableName, IEnumerable<T> records, CancellationToken ct = default);
Task TruncateTableAsync(string tableName, CancellationToken ct = default);
}
public interface IJdeRepository
{
// Work Order Data
IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrder> GetWorkOrdersArchiveAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsArchiveAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesArchiveAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderRouting> GetWorkOrderRoutingsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsArchiveAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
// Lot Data
IAsyncEnumerable<Lot> GetLotsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<LotUsage> GetLotUsagesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<LotUsage> GetLotUsagesArchiveAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<LotLocation> GetLotLocationsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
// Reference Data
IAsyncEnumerable<Item> GetItemsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<JdeUser> GetUsersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<Branch> GetBranchesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<ProfitCenter> GetProfitCentersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<WorkCenter> GetWorkCentersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<StatusCode> GetStatusCodesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<FunctionCode> GetFunctionCodesAsync(CancellationToken ct = default);
IAsyncEnumerable<OrgHierarchy> GetOrgHierarchyAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
IAsyncEnumerable<RouteMaster> GetRouteMastersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
}
public interface ICmsRepository
{
IAsyncEnumerable<MisData> GetMisDataAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default);
}
```
#### Business Rules
- All interfaces SHALL be registered with the DI container as scoped services
- Implementations SHALL accept `IDbConnectionFactory` via constructor injection
- Implementations SHALL accept `ILogger<T>` via constructor injection
- Implementations SHALL accept `IOptions<DataAccessOptions>` via constructor injection
#### Scenario: Repository injection
- **WHEN** a service requires data access and requests `ILotFinderRepository` via DI
- **THEN** the container provides a configured `LotFinderRepository` instance with connection factory, logger, and options injected
---
### Requirement: Connection Factory Pattern
The system SHALL provide a connection factory abstraction for database connections.
#### Interface Definition
```csharp
public interface IDbConnectionFactory
{
Task<SqlConnection> CreateLotFinderConnectionAsync(CancellationToken ct = default);
Task<OracleConnection> CreateJdeConnectionAsync(CancellationToken ct = default);
Task<OracleConnection> CreateJdeStageConnectionAsync(CancellationToken ct = default);
Task<OracleConnection> CreateCmsConnectionAsync(CancellationToken ct = default);
}
```
#### Business Rules
- Connection factory SHALL use `Microsoft.Data.SqlClient.SqlConnection` for SQL Server
- Connection factory SHALL use `Oracle.ManagedDataAccess.Core.OracleConnection` for all Oracle connections (JDE, JDE Stage, CMS)
- Connection strings SHALL be retrieved from `IConfiguration["ConnectionStrings:*"]`
- Secrets (passwords) SHALL be retrieved from Azure Key Vault or .NET Secret Manager (never stored in config files)
- Connections SHALL be opened asynchronously before returning
- Connection factory SHALL be registered as a singleton service
#### Scenario: Create SQL Server connection
- **WHEN** `CreateLotFinderConnectionAsync()` is called
- **THEN** a new `SqlConnection` is created with connection string from configuration, opened asynchronously, and returned
#### Scenario: Create Oracle connection
- **WHEN** `CreateJdeConnectionAsync()` is called
- **THEN** a new `OracleConnection` is created with connection string from configuration, opened asynchronously, and returned
---
### Requirement: Service Registration
The system SHALL provide DI extension methods for service registration.
#### Method Signature
```csharp
public static IServiceCollection AddDataAccess(this IServiceCollection services, IConfiguration configuration)
```
#### Business Rules
- Extension method SHALL register `IDbConnectionFactory` as singleton
- Extension method SHALL register `ILotFinderRepository` as scoped
- Extension method SHALL register `IJdeRepository` as scoped
- Extension method SHALL register `ICmsRepository` as scoped
- Extension method SHALL bind `DataAccessOptions` from configuration section "DataAccess"
#### Scenario: Register data access services
- **WHEN** `services.AddDataAccess(configuration)` is called during startup
- **THEN** all repository interfaces and connection factory are registered with appropriate lifetimes
---
### Requirement: Configuration Options
The system SHALL support configurable timeouts via `IOptions<DataAccessOptions>`.
#### Options Class
```csharp
public class DataAccessOptions
{
public int DefaultTimeoutSeconds { get; set; } = 600;
public int LotUsageTimeoutSeconds { get; set; } = 999999;
public int MisDataTimeoutSeconds { get; set; } = 60000;
public int RebuildIndexTimeoutSeconds { get; set; } = 600;
public string ProductionSchema { get; set; } = "PRODDTA";
public string ArchiveSchema { get; set; } = "ARCDTAPD";
public string StageSchema { get; set; } = "JDESTAGE";
}
```
#### Business Rules
- Default timeout SHALL be 600 seconds (10 minutes) for general queries
- LotUsage filtered queries SHALL use configurable timeout (default 999999 seconds)
- MIS data queries SHALL use configurable timeout (default 60000 seconds)
- Schema names SHALL be configurable for environment-specific deployments
---
### Requirement: Connection factory pattern
The system SHALL implement `IDbConnectionFactory` to provide database connections via dependency injection.
#### Implementation Pattern
```csharp
public class DbConnectionFactory : IDbConnectionFactory
{
private readonly IConfiguration _configuration;
private readonly ILogger<DbConnectionFactory> _logger;
public DbConnectionFactory(IConfiguration configuration, ILogger<DbConnectionFactory> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task<SqlConnection> CreateLotFinderConnectionAsync(CancellationToken ct = default)
{
var connectionString = _configuration.GetConnectionString("LotFinderDB")
?? throw new ConnectionException("LotFinderDB connection string not configured", "LotFinderDB");
var connection = new SqlConnection(connectionString);
try
{
await connection.OpenAsync(ct);
return connection;
}
catch (SqlException ex)
{
_logger.LogError(ex, "Failed to connect to LotFinderDB");
await connection.DisposeAsync();
throw new ConnectionException("LotFinderDB: failed to open connection to database.", "LotFinderDB", ex);
}
}
}
```
#### Business Rules
- Connection factory SHALL be registered as singleton
- Connections SHALL be opened asynchronously before returning
- Callers SHALL dispose returned connections when finished
- `ConnectionException` SHALL be thrown on connection failure with inner exception preserved
#### Scenario: Successful connection creation
- **WHEN** valid connection string exists in configuration
- **AND** `CreateLotFinderConnectionAsync()` is called
- **THEN** a new `SqlConnection` is created, opened, and returned
- **AND** the caller is responsible for disposal
#### Scenario: Connection failure handling
- **WHEN** database is unreachable
- **AND** `CreateLotFinderConnectionAsync()` is called
- **THEN** error is logged with exception details
- **AND** `ConnectionException` is thrown with descriptive message
- **AND** inner exception is preserved for debugging
---
### Requirement: Async streaming pattern
The system SHALL use `IAsyncEnumerable<T>` with Dapper's `QueryUnbufferedAsync` for streaming large result sets.
#### Implementation Pattern
```csharp
public async IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(
DateTime? lastUpdateDT = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
await using var connection = await _connectionFactory.CreateJdeConnectionAsync(ct);
var sql = ApplySchemaPlaceholders(
lastUpdateDT.HasValue
? JdeQueries.SQL_GET_WORKORDERS_FILTERED
: JdeQueries.SQL_GET_WORKORDERS);
var command = new CommandDefinition(
sql,
parameters: lastUpdateDT.HasValue
? new { dateUpdated = ToJdeDate(lastUpdateDT.Value), timeUpdated = ToJdeTime(lastUpdateDT.Value) }
: null,
commandTimeout: _options.Value.DefaultTimeoutSeconds,
cancellationToken: ct);
await foreach (var workOrder in connection.QueryUnbufferedAsync<WorkOrder>(command).WithCancellation(ct))
{
yield return workOrder;
}
}
```
#### Business Rules
- All JDE/CMS collection queries SHALL return `IAsyncEnumerable<T>`
- Queries SHALL use `QueryUnbufferedAsync` to stream results
- Methods SHALL accept `CancellationToken` with `[EnumeratorCancellation]` attribute
- Cancellation SHALL be checked between row iterations via `WithCancellation(ct)`
#### Scenario: Stream large work order dataset
- **WHEN** `GetWorkOrdersAsync()` is called for 1 million work orders
- **THEN** results are streamed via `IAsyncEnumerable<WorkOrder>` one at a time
- **AND** memory usage remains constant regardless of result set size
- **AND** consumer can use `await foreach` syntax
#### Scenario: Cancel streaming operation
- **WHEN** cancellation is requested during `GetWorkOrdersAsync()` iteration
- **THEN** iteration stops after current row completes
- **AND** `OperationCanceledException` is thrown to consumer
- **AND** database connection is properly disposed
---
### Requirement: Schema placeholder replacement
The system SHALL replace schema placeholders in SQL queries from `DataAccessOptions`.
#### Implementation Pattern
```csharp
private string ApplySchemaPlaceholders(string sql)
{
return sql
.Replace("{ProductionSchema}", _options.Value.ProductionSchema)
.Replace("{ArchiveSchema}", _options.Value.ArchiveSchema)
.Replace("{StageSchema}", _options.Value.StageSchema);
}
```
#### Business Rules
- Schema names SHALL be configurable for environment-specific deployments
- Default values: PRODDTA (production), ARCDTAPD (archive), JDESTAGE (stage)
- Replacement SHALL occur at query execution time
#### Scenario: Query uses production schema
- **WHEN** SQL query contains `{ProductionSchema}.F4801`
- **AND** `DataAccessOptions.ProductionSchema` is "PRODDTA"
- **THEN** query is executed with `PRODDTA.F4801`
---
### Requirement: Table name whitelist validation
The system SHALL validate table names against an explicit whitelist to prevent SQL injection in `RebuildIndicesAsync`.
#### Implementation Pattern
```csharp
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"
};
public async Task RebuildIndicesAsync(string tableName, CancellationToken ct = default)
{
if (!ValidTableNames.Contains(tableName))
{
throw new ArgumentException($"Invalid table name: {tableName}", nameof(tableName));
}
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);
}
```
#### Business Rules
- Table name MUST be validated against explicit whitelist before execution
- `ArgumentException` SHALL be thrown for invalid table names
- Comparison SHALL be case-insensitive
#### Scenario: Valid table name accepted
- **WHEN** `RebuildIndicesAsync("WorkOrder_Curr")` is called
- **THEN** table name passes whitelist validation
- **AND** index rebuild executes successfully
#### Scenario: SQL injection attempt blocked
- **WHEN** `RebuildIndicesAsync("WorkOrder]; DROP TABLE Search;--")` is called
- **THEN** table name fails whitelist validation
- **AND** `ArgumentException` is thrown with message "Invalid table name"
- **AND** no SQL is executed
---
### Requirement: Exception logging with scope context
The system SHALL log exceptions with structured scope context before throwing.
#### Implementation Pattern
```csharp
catch (OracleException ex)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["DataSource"] = "JDE",
["Operation"] = nameof(GetWorkOrdersAsync),
["QueryName"] = "SQL_GET_WORKORDERS"
}))
{
_logger.LogError(ex, "Query execution failed");
}
throw new QueryException("Failed to execute work order query", "SQL_GET_WORKORDERS", ex);
}
```
#### Business Rules
- All exceptions SHALL be logged at throw site
- Log context SHALL include: DataSource, Operation, and QueryName where applicable
- Inner exceptions SHALL be preserved in thrown exceptions
- Structured logging SHALL enable log aggregation and analysis
#### Scenario: Query exception with full context
- **WHEN** JDE Oracle query fails with OracleException
- **THEN** error is logged with BeginScope containing DataSource, Operation, QueryName
- **AND** `QueryException` is thrown with descriptive message and inner exception
---
### Requirement: Search criteria extraction via SQL functions
The system SHALL use SQL extraction functions to retrieve filter criteria directly from the `Search.Criteria` JSON column.
#### Implementation Pattern
Search query building now uses SearchId to invoke extraction functions rather than passing filter values from C#:
```csharp
public SearchQueryResult BuildSearchQuery(int searchId)
{
// Query builder generates SQL that calls extraction functions
// Example generated SQL fragment:
// INSERT INTO #P_WorkOrders SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(@SearchId)
// INSERT INTO #P_ItemNumbers SELECT ItemNumber FROM dbo.fn_GetSearchItemNumbers(@SearchId)
return new SearchQueryResult(sql, new { SearchId = searchId });
}
```
#### Business Rules
- Query builder SHALL accept only `searchId` parameter (not full criteria object)
- SQL queries SHALL call extraction functions to populate temporary filter tables
- Extraction functions handle JSON parsing and validation in SQL Server
- Invalid JSON or missing criteria results in empty filter sets (no errors thrown)
#### Available Extraction Functions
| Function | Returns |
|----------|---------|
| `fn_GetSearchMinimumDt` | DATETIME2 scalar |
| `fn_GetSearchMaximumDt` | DATETIME2 scalar |
| `fn_GetSearchExtractMisData` | BIT scalar |
| `fn_GetSearchWorkOrders` | WorkOrderNumber table |
| `fn_GetSearchItemNumbers` | ItemNumber table |
| `fn_GetSearchProfitCenters` | Code table |
| `fn_GetSearchWorkCenters` | Code table |
| `fn_GetSearchOperatorIDs` | OperatorID table |
| `fn_GetSearchComponentLots` | LotNumber, ItemNumber table |
| `fn_GetSearchPartOperations` | ItemNumber, OperationNumber, MisNumber, MisRevision table |
#### Scenario: Build search query with extraction functions
- **WHEN** `BuildSearchQuery(123)` is called for a search with work order filter
- **THEN** generated SQL includes `SELECT WorkOrderNumber FROM dbo.fn_GetSearchWorkOrders(123)`
- **AND** only the `@SearchId` parameter is passed to the query
---
### Requirement: Table-valued parameter support for lookups
The system SHALL use DataTable for SQL Server table-valued parameters in reference data lookup methods.
#### Business Rules
- TVPs are used for batch lookups (LookupItemsAsync, LookupWorkordersAsync, etc.)
- TVPs are NOT used for search query execution (replaced by extraction functions)
- DataTable column names SHALL match TVP type column names
#### Scenario: Batch lookup with TVP
- **WHEN** `LookupItemsAsync(["ITEM001", "ITEM002", "ITEM003"])` is called
- **THEN** DataTable is created with ItemNumber column
- **AND** single query executes with TVP parameter
- **AND** matching items are returned
---
### Requirement: Service registration extension method
The system SHALL provide `AddDataAccess` extension method for DI registration.
#### Implementation Pattern
```csharp
public static IServiceCollection AddDataAccess(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<DataAccessOptions>(
configuration.GetSection(DataAccessOptions.SectionName));
services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>();
services.AddScoped<ILotFinderRepository, LotFinderRepository>();
services.AddScoped<IJdeRepository, JdeRepository>();
services.AddScoped<ICmsRepository, CmsRepository>();
return services;
}
```
#### Business Rules
- Extension method SHALL bind `DataAccessOptions` from "DataAccess" configuration section
- Connection factory SHALL be registered as singleton
- Repositories SHALL be registered as scoped services
- Method SHALL return `IServiceCollection` for chaining
#### Scenario: Register all data access services
- **WHEN** `services.AddDataAccess(configuration)` is called during startup
- **THEN** `DataAccessOptions` is bound from configuration
- **AND** `IDbConnectionFactory` is registered as singleton
- **AND** all repository interfaces are registered as scoped
## Exception Handling
### Requirement: Custom Exception Types
The system SHALL define custom exception types for data access errors.
#### Exception Hierarchy
```csharp
public class DataAccessException : Exception
{
public string? Operation { get; }
public string? Repository { get; }
public DataAccessException(string message, string? operation = null, string? repository = null, Exception? inner = null);
}
public class ConnectionException : DataAccessException
{
public string? DataSource { get; }
public ConnectionException(string message, string dataSource, Exception? inner = null);
}
public class QueryException : DataAccessException
{
public string? QueryName { get; }
public QueryException(string message, string queryName, Exception? inner = null);
}
public class DataAccessTimeoutException : DataAccessException
{
public int TimeoutSeconds { get; }
public DataAccessTimeoutException(string message, int timeoutSeconds, Exception? inner = null);
}
```
#### Business Rules
- All repository methods SHALL throw typed exceptions on error (never return null/empty on error)
- `ConnectionException` SHALL be thrown for connection failures
- `QueryException` SHALL be thrown for query execution failures
- `DataAccessTimeoutException` SHALL be thrown for timeout errors
- Exception SHALL be logged at throw site via `ILogger<T>` with `BeginScope()` for context
- Inner exceptions SHALL be preserved for debugging
#### Scenario: Connection failure logging
- **WHEN** a connection to JDE Oracle fails
- **THEN** error is logged with scope context (data source, operation) and `ConnectionException` is thrown with descriptive message and inner exception
#### Scenario: Query timeout
- **WHEN** a query exceeds configured timeout
- **THEN** `DataAccessTimeoutException` is thrown with timeout value and query name
---
## Query Management
### Requirement: QueryRepository component
The system SHALL manage SQL queries as embedded resources or compile-time constants.
#### Business Rules
- SQL queries SHALL be stored as embedded resources in the assembly or as `const string` fields
- Queries SHALL be loaded once at application startup and cached in memory
- Schema placeholder replacement (`PRODDTA`, `ARCDTAPD`, `JDESTAGE`) SHALL use values from `DataAccessOptions`
- Query loading SHALL throw `QueryException` if query is not found
#### Scenario: Load query from embedded resource
- **WHEN** repository is instantiated and SQL queries are needed
- **THEN** queries are loaded from embedded resources with schema placeholders replaced from configuration
#### Scenario: Query not found
- **WHEN** a requested query name does not exist in embedded resources
- **THEN** `QueryException` is thrown with message containing the query name
---
## Connection Management
### Requirement: Connection Management - LotFinderDB component
The system SHALL provide SQL Server cache database connections via `IDbConnectionFactory`.
#### Method: CreateLotFinderConnectionAsync
Opens a new connection to the LotFinderDB SQL Server database.
| Parameter | Type | Description |
|-----------|------|-------------|
| ct | CancellationToken | Cancellation token for async operation |
| Returns | Type | Description |
|---------|------|-------------|
| connection | Task<SqlConnection> | Open connection to LotFinderDB |
#### Business Rules
- Connection string loaded from `IConfiguration["ConnectionStrings:LotFinderDB"]`
- Password retrieved from Azure Key Vault or .NET Secret Manager
- Uses `Microsoft.Data.SqlClient.SqlConnection`
- Returns opened connection (caller must dispose)
- Throws `ConnectionException` on failure with descriptive message
- Default command timeout: configurable via `DataAccessOptions.DefaultTimeoutSeconds`
#### Scenario: Successful connection
- **WHEN** valid LotFinderDB connection string exists in configuration and `CreateLotFinderConnectionAsync()` is called
- **THEN** a new `SqlConnection` is created, opened asynchronously, and returned
#### Scenario: Connection failure
- **WHEN** invalid or unreachable database exists and `CreateLotFinderConnectionAsync()` is called
- **THEN** error is logged via `ILogger<T>` and `ConnectionException` is thrown with message "LotFinderDB: failed to open connection to database."
---
### Requirement: Connection Management - JDE component
The system SHALL provide JDE Oracle database connections via `IDbConnectionFactory`.
#### Method: CreateJdeConnectionAsync
Opens a new connection to the JDE Oracle database.
| Parameter | Type | Description |
|-----------|------|-------------|
| ct | CancellationToken | Cancellation token for async operation |
| Returns | Type | Description |
|---------|------|-------------|
| connection | Task<OracleConnection> | Open connection to JDE (Oracle.ManagedDataAccess.Core) |
#### Business Rules
- Connection string loaded from `IConfiguration["ConnectionStrings:JDE"]`
- Password retrieved from Azure Key Vault or .NET Secret Manager
- Uses `Oracle.ManagedDataAccess.Core.OracleConnection`
- Returns opened connection (caller must dispose)
- Throws `ConnectionException` on failure
#### Scenario: Successful JDE connection
- **WHEN** valid JDE Oracle connection string exists and `CreateJdeConnectionAsync()` is called
- **THEN** a new OracleConnection is created, opened asynchronously, and returned
---
### Requirement: Connection Management - JDE Stage component
The system SHALL provide a separate JDE connection for stage/view queries.
#### Method: CreateJdeStageConnectionAsync
Opens a new connection to the JDE Stage database (for JDESTAGE schema views).
#### Business Rules
- Connection string loaded from `IConfiguration["ConnectionStrings:JDEStage"]`
- Uses `Oracle.ManagedDataAccess.Core.OracleConnection`
- Used for `GetStatusCodes()` and `GetLotLocations()` methods
- Queries JDESTAGE schema views (F0005_VIEW, F41021_VIEW)
---
### Requirement: Connection Management - CMS component
The system SHALL provide CMS database connections via `IDbConnectionFactory`.
#### Method: CreateCmsConnectionAsync
Opens a new connection to the CMS database.
| Parameter | Type | Description |
|-----------|------|-------------|
| ct | CancellationToken | Cancellation token for async operation |
| Returns | Type | Description |
|---------|------|-------------|
| connection | Task<OracleConnection> | Open connection to CMS |
#### Business Rules
- Connection string loaded from `IConfiguration["ConnectionStrings:CMS"]`
- Password retrieved from Azure Key Vault or .NET Secret Manager
- Uses `Oracle.ManagedDataAccess.Core.OracleConnection` (consolidated from legacy DDTek.Oracle)
---
## Async Streaming
### Requirement: IAsyncEnumerable Streaming
The system SHALL use `IAsyncEnumerable<T>` for streaming large datasets.
#### Business Rules
- All JDE/CMS query methods returning collections SHALL return `IAsyncEnumerable<T>`
- Implementations SHALL use Dapper's `QueryUnbufferedAsync` for streaming results
- All streaming methods SHALL accept `CancellationToken` parameter
- Cancellation SHALL be checked between row iterations
#### Scenario: Stream work orders
- **WHEN** `GetWorkOrdersAsync()` is called with large dataset
- **THEN** results are streamed via `IAsyncEnumerable<WorkOrder>` without loading all rows into memory
#### Scenario: Cancel streaming operation
- **WHEN** cancellation is requested during `GetWorkOrdersAsync()` iteration
- **THEN** iteration stops and `OperationCanceledException` is thrown
---
## JDE Oracle Queries
### Requirement: JDE.GetWorkOrders component
The system SHALL fetch work order records from JDE F4801 table.
#### Method Signature
```csharp
IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| lastUpdateDT | DateTime? | Optional cutoff for incremental sync |
| ct | CancellationToken | Cancellation token |
#### Returns
- `IAsyncEnumerable<WorkOrder>` - Streaming results via QueryUnbufferedAsync
#### Query: SQL_GET_WORKORDERS
```sql
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
```
#### Query: SQL_GET_WORKORDERS_FILTERED (Incremental)
Same SELECT with WHERE clause:
```sql
WHERE (wo.WAUPMJ > :dateUpdated OR
(wo.WAUPMJ = :dateUpdated AND wo.WATDAY >= :timeUpdated))
```
#### Query: SQL_GET_WORKORDERS_ARCHIVE
Same SELECT from `{ArchiveSchema}.F4801` (archive schema)
#### JDE Table Mapping
| JDE Column | Domain Property | Transformation |
|------------|-----------------|----------------|
| WADOCO | WorkOrderNumber | Direct |
| WAMMCU | BranchCode | TRIM |
| WALOTN | LotNumber | TRIM |
| WALITM | ItemNumber | TRIM |
| WAITM | ShortItemNumber | Direct |
| WAPARS | ParentWorkOrderNumber | TRIM |
| WAUORG | OrderQuantity | / 100.0 |
| WASOBK | HeldQuantity | / 100.0 |
| WASOQS | ShippedQuantity | / 100.0 |
| WASRST | StatusCode | TRIM |
| WADCG | StatusCodeUpdateDT | JDE date conversion |
| WATRDJ | IssueDate | JDE date conversion |
| WASTRT | StartDate | JDE date conversion |
| WATRT | RoutingType | TRIM |
| WAUPMJ | LastUpdateDate | JDE date (integer) |
| WATDAY | LastUpdateTime | JDE time (integer) |
#### Business Rules
- Quantities stored in JDE as integer * 100, divided for decimal values
- JDE dates stored as integer CYYDDD format, converted to DATE
- Zero dates converted to '1900-01-01'
- Uses configurable query timeout from `DataAccessOptions.DefaultTimeoutSeconds`
- Streaming results via `QueryUnbufferedAsync` for memory efficiency
- Schema placeholder `{ProductionSchema}` replaced from configuration
#### Scenario: Full sync (mass update)
- **WHEN** lastUpdateDT is null and `GetWorkOrdersAsync()` is called
- **THEN** SQL_GET_WORKORDERS query returns all work orders from JDE as async stream
#### Scenario: Incremental sync (daily/hourly update)
- **WHEN** lastUpdateDT is 2024-01-15 and `GetWorkOrdersAsync(lastUpdateDT)` is called
- **THEN** SQL_GET_WORKORDERS_FILTERED returns only records updated since that date
#### Scenario: JDE quantity conversion
- **WHEN** JDE WAUORG value is 10050 (integer) and mapping to WorkOrder.OrderQuantity
- **THEN** OrderQuantity equals 100.50 (decimal, divided by 100)
---
### Requirement: JDE.GetWorkOrderSteps component
The system SHALL fetch work order operation steps from JDE F3112 table.
#### Method Signature
```csharp
IAsyncEnumerable<WorkOrderStep> GetWorkOrderStepsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_WORKORDER_STEP
```sql
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
```
#### Business Rules
- StepNumber is WLOPSQ/10 (stored as tenths in JDE)
- LEFT OUTER JOIN to F00192 for FunctionOperationDescription
- Filters out records with NULL work center or branch
- Archive queries use `{ArchiveSchema}.F3112`
#### Scenario: Step number conversion
- **WHEN** JDE WLOPSQ value is 100 (tenths) and mapping to WorkOrderStep.StepNumber
- **THEN** StepNumber equals 10.0 (decimal)
#### Scenario: Enrich with function description
- **WHEN** step has FunctionCode "ASSY" and LEFT OUTER JOIN to F00192 succeeds
- **THEN** FunctionOperationDescription is populated from media object
---
### Requirement: JDE.GetWorkOrderTimes component
The system SHALL fetch work order time transactions (operator labor) from JDE F31122 table.
#### Method Signature
```csharp
IAsyncEnumerable<WorkOrderTime> GetWorkOrderTimesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_WORKORDER_TIMES
```sql
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
```
#### Business Rules
- Links operators to work orders via AddressNumber -> JdeUser
- Filters out records with NULL work center or branch
---
### Requirement: JDE.GetWorkOrderRoutings component
The system SHALL fetch work order routing transactions from JDE F3112Z1 table.
#### Method Signature
```csharp
IAsyncEnumerable<WorkOrderRouting> GetWorkOrderRoutingsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_WORKORDER_ROUTING
```sql
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
```
#### Business Rules
- Filters by specific transaction type (JDERTG), direction (2), action (02), program ID (ER31410)
- Used for MIS matching to find original operation sequence
- **Data validation**: Skips records where LastUpdateDT or TransactionDate year < 1900 or > 2500
- No archive table (single table for all data)
---
### Requirement: JDE.GetWorkOrderComponents component
The system SHALL fetch work order component usage from JDE F3111 table.
#### Method Signature
```csharp
IAsyncEnumerable<WorkOrderComponent> GetWorkOrderComponentsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_WORKORDER_COMPONENTS
```sql
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
```
#### Business Rules
- Filters records with NULL lot number
- Archive queries use `{ArchiveSchema}.F3111`
---
### Requirement: JDE.GetLots component
The system SHALL fetch lot master data from JDE F4108 table.
#### Method Signature
```csharp
IAsyncEnumerable<Lot> GetLotsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_LOTS
```sql
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,
lot.IOLOT1 AS Memo1,
lot.IOLOT2 AS Memo2,
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
```
#### Business Rules
- StatusCode (IOLOTS) is single character
- Filters out records with NULL lot number or branch code
---
### Requirement: JDE.GetLotUsages component
The system SHALL fetch lot usage (cardex) transactions from JDE F4111 table.
#### Method Signature
```csharp
IAsyncEnumerable<LotUsage> GetLotUsagesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_LOT_USAGES
```sql
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
```
#### Business Rules
- Filters by document type 'IM' (inventory management)
- Filters out records with NULL lot number
- **Special timeout**: Filtered query uses `DataAccessOptions.LotUsageTimeoutSeconds` (default 999999)
- Archive queries use `{ArchiveSchema}.F4111`
---
### Requirement: JDE.GetLotLocations component
The system SHALL fetch lot location tracking from JDESTAGE.F41021_VIEW.
#### Method Signature
```csharp
IAsyncEnumerable<LotLocation> GetLotLocationsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_LOT_LOCATIONS
```sql
SELECT TRIM(il.LOT_LILOTN) AS LotNumber,
il.IDENTIFIERSHORTITEM_LIITM AS ShortItemNumber,
TRIM(il.COSTCENTER_LIMCU) AS BranchCode,
COALESCE(TRIM(il.LOCATION_LILOCN), ' ') AS Location,
il.DATEUPDATED_LIUPMJ + FLOOR(il.TIMEOFDAY_LITDAY / 10000) / 24 +
FLOOR(MOD(il.TIMEOFDAY_LITDAY, 10000) / 100) / 1440 +
MOD(il.TIMEOFDAY_LITDAY, 100) / 86400 AS LastUpdateDT
FROM {StageSchema}.F41021_VIEW il
WHERE TRIM(il.LOT_LILOTN) IS NOT NULL
```
#### Business Rules
- Uses JDESTAGE view via `CreateJdeStageConnectionAsync()` (different connection than main JDE)
- Location defaults to space if NULL
- LastUpdateDT computed inline from JDE date/time integers
---
### Requirement: JDE.GetItems component
The system SHALL fetch item (part number) master data from JDE F4101 table.
#### Method Signature
```csharp
IAsyncEnumerable<Item> GetItemsAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_ITEMS
```sql
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
```
---
### Requirement: JDE.GetUsers component
The system SHALL fetch user/operator data from JDE F0101 and SY920.F0092 tables.
#### Method Signature
```csharp
IAsyncEnumerable<JdeUser> GetUsersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_USERS
```sql
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
SY920.F0092 pro ON (ab.ABAN8 = pro.ULAN8)
)
SELECT AddressNumber,
UserID,
FullName,
LastUpdateDate,
LastUpdateTime
FROM USER_CTE
WHERE RN = 1
```
#### Business Rules
- CTE with ROW_NUMBER to get latest record per AddressNumber
- LEFT OUTER JOIN to SY920 schema for UserID lookup
- **Note**: Incremental filtering not supported for users (full sync always)
---
### Requirement: JDE.GetBranches / GetProfitCenters / GetWorkCenters component
The system SHALL fetch business unit reference data from JDE F0006 table.
#### Method Signature
```csharp
IAsyncEnumerable<Branch> GetBranchesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
IAsyncEnumerable<ProfitCenter> GetProfitCentersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
IAsyncEnumerable<WorkCenter> GetWorkCentersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_BUSINESS_UNITS
```sql
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
```
#### Parameters
| Entity | typeCode Parameter |
|--------|-------------------|
| Branch | 'BP' |
| ProfitCenter | 'I3' |
| WorkCenter | 'WC' |
---
### Requirement: JDE.GetStatusCodes component
The system SHALL fetch work order status codes from JDESTAGE.F0005_VIEW.
#### Method Signature
```csharp
IAsyncEnumerable<StatusCode> GetStatusCodesAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_STATUS_CODES
```sql
SELECT TRIM(sc.USERDEFINEDCODE_DRKY) AS CODE,
TRIM(sc.DESCRIPTION001_DRDL01) AS Description,
sc.DATEUPDATED_DRUPMJ + FLOOR(sc.TIMELASTUPDATED_DRUPMT / 10000) / 24 +
FLOOR(MOD(sc.TIMELASTUPDATED_DRUPMT, 10000) / 100) / 1440 +
MOD(sc.TIMELASTUPDATED_DRUPMT, 100) / 86400 AS LastUpdateDT
FROM {StageSchema}.F0005_VIEW sc
WHERE TRIM(sc.PRODUCTCODE_DRSY) = '00' AND
sc.USERDEFINEDCODES_DRRT = 'SS' AND
TRIM(sc.USERDEFINEDCODE_DRKY) IS NOT NULL
```
#### Business Rules
- Uses JDE Stage connection via `CreateJdeStageConnectionAsync()` (different connection than main JDE)
- Filters by product code '00' and UDC type 'SS'
- LastUpdateDT computed inline from date/time integers
---
### Requirement: JDE.GetFunctionCodes component
The system SHALL fetch function code lookup data from JDE F00192 table.
#### Method Signature
```csharp
IAsyncEnumerable<FunctionCode> GetFunctionCodesAsync(CancellationToken ct = default)
```
#### Query: SQL_GET_FUNCTION_CODES
```sql
SELECT Code,
TRIM(LISTAGG(Description, ' ') WITHIN GROUP(ORDER BY Description) ||
CASE WHEN MAX(total_lengthb) > 4000 THEN '...' ELSE '' END) 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
```
#### Business Rules
- Aggregates multiple description rows per code using LISTAGG
- Truncates at 4000 bytes with '...' suffix
- Uses ASCIISTR to handle special characters
- LastUpdateDT set to SYSDATE (current timestamp)
- **Note**: Does not support incremental filtering (no lastUpdateDT parameter)
---
### Requirement: JDE.GetOrgHierarchy component
The system SHALL fetch organization hierarchy (work center to profit center mapping) from JDE F30006 table.
#### Method Signature
```csharp
IAsyncEnumerable<OrgHierarchy> GetOrgHierarchyAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_ORG_HIERARCHY
```sql
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
```
---
### Requirement: JDE.GetRouteMasters component
The system SHALL fetch item routing master data from JDE F3003 table.
#### Method Signature
```csharp
IAsyncEnumerable<RouteMaster> GetRouteMastersAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_ROUTE_MASTER
```sql
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 StartDate_Date,
rm.IREFFT AS EndDate_Date,
rm.IRUPMJ AS LastUpdateDate,
rm.IRTDAY AS LastUpdateTime
FROM {ProductionSchema}.F3003 rm
WHERE TRIM(rm.IRKITL) IS NOT NULL
```
#### Business Rules
- SequenceNumber is IROPSQ/10 (stored as tenths)
- StartDate_Date and EndDate_Date are JDE integer format (converted in model)
---
## CMS Queries
### Requirement: CMS.GetMisData component
The system SHALL fetch Manufacturing Information System (MIS) data from CMS database.
#### Method Signature
```csharp
IAsyncEnumerable<MisData> GetMisDataAsync(DateTime? lastUpdateDT = null, CancellationToken ct = default)
```
#### Query: SQL_GET_MIS_DATA
```sql
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')
```
#### Business Rules
- Complex 10-table JOIN through CMS schema (INFODBA)
- Filters for Status 'Current' or 'BackLevel' only
- **Special timeout**: Uses `DataAccessOptions.MisDataTimeoutSeconds` (default 60000 seconds)
- **Data transformation**: ReleaseDate converted to local time
- Uses CMS connection via `CreateCmsConnectionAsync()`
#### Scenario: Fetch MIS data from CMS
- **WHEN** CMS connection is available and `GetMisDataAsync()` is called
- **THEN** 10-table JOIN returns Current and BackLevel MIS records as async stream
#### Scenario: Handle long-running query
- **WHEN** MIS data query may take extended time and query executes
- **THEN** configured timeout allows completion without error
---
## SQL Server Cache Operations
### Requirement: LotFinderDB.GetUserSearches component
The system SHALL retrieve searches for a specific user.
#### Method Signature
```csharp
Task<List<Search>> GetUserSearchesAsync(string userName, CancellationToken ct = default)
```
#### Query
```sql
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
```
#### Business Rules
- Throws `QueryException` on error (does not return empty list on error)
- Does not include Criteria or Results (lightweight query)
- Ordered by SubmitDT ascending
#### Scenario: Get user's search history
- **WHEN** user "jdoe" has 5 previous searches and `GetUserSearchesAsync("jdoe")` is called
- **THEN** 5 Search objects are returned without Criteria/Results (lightweight)
---
### Requirement: LotFinderDB.GetQueuedSearches component
The system SHALL retrieve all searches pending processing.
#### Method Signature
```csharp
Task<List<Search>> GetQueuedSearchesAsync(CancellationToken ct = default)
```
#### Query
```sql
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
```
#### Business Rules
- Status < 3 = New (0), Submitted (1), Started (2)
- Throws `QueryException` on error
- Ordered by SubmitDT ascending (FIFO processing)
#### Scenario: Worker polls for pending searches
- **WHEN** 3 searches with Status = Submitted exist and `GetQueuedSearchesAsync()` is called
- **THEN** 3 Search objects are returned ordered by SubmitDT (FIFO)
#### Scenario: Exclude completed searches
- **WHEN** 2 searches with Status = Ended (3) exist and `GetQueuedSearchesAsync()` is called
- **THEN** those searches are not included (Status < 3 filter)
---
### Requirement: LotFinderDB.GetSearch component
The system SHALL retrieve a single search by ID with criteria.
#### Method Signature
```csharp
Task<Search?> GetSearchAsync(int id, CancellationToken ct = default)
```
#### Query
```sql
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
```
#### Business Rules
- Returns null if not found
- Throws `QueryException` on database error
- Deserializes CriteriaJSON to SearchCriteria object
- Sets ID property manually after query (not in SELECT)
---
### Requirement: LotFinderDB.GetSearchResults component
The system SHALL retrieve Excel results for a completed search.
#### Method Signature
```csharp
Task<byte[]?> GetSearchResultsAsync(int id, CancellationToken ct = default)
```
#### Query
```sql
SELECT s.Results
FROM dbo.Search AS s
WHERE s.ID = @id
```
#### Business Rules
- Returns null if not found
- Throws `QueryException` on database error
- Results is VARBINARY(MAX) containing Excel file
---
### Requirement: LotFinderDB.SubmitSearch component
The system SHALL create a new search request.
#### Method Signature
```csharp
Task<int> SubmitSearchAsync(Search search, CancellationToken ct = default)
```
#### Stored Procedure
```sql
EXEC dbo.usp_SubmitSearch
@p_UserName = ...,
@p_Name = ...,
@p_Criteria = ...,
@o_SearchID = ... OUTPUT
```
#### Business Rules
- Sets Status = Submitted and SubmitDT = DateTime.UtcNow before insert
- Serializes Criteria to JSON via ToJSON() method
- Throws `QueryException` on error
- Uses stored procedure with OUTPUT parameter for new ID
---
### Requirement: LotFinderDB.SearchItems component
The system SHALL provide autocomplete search for items by number or description.
#### Method Signature
```csharp
Task<List<Item>> SearchItemsAsync(string filter, CancellationToken ct = default)
```
#### Query
```sql
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
```
#### Business Rules
- Returns top 25 matches
- Case-insensitive LIKE search on both ItemNumber and Description
- Throws `QueryException` on error
---
### Requirement: LotFinderDB.LookupItems component
The system SHALL provide batch lookup of items by exact item numbers.
#### Method Signature
```csharp
Task<List<Item>> LookupItemsAsync(List<string> itemNumbers, CancellationToken ct = default)
```
#### Query
```sql
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
```
#### Business Rules
- Uses table-valued parameter `ItemNumberFilterParameter`
- Throws `QueryException` on error
---
### Requirement: LotFinderDB.LookupWorkorders component
The system SHALL provide batch lookup of work orders by work order numbers.
#### Method Signature
```csharp
Task<List<WorkOrder>> LookupWorkordersAsync(List<long> workorderNumbers, CancellationToken ct = default)
```
#### Query
```sql
SELECT *
FROM dbo.WorkOrder AS wo
INNER JOIN @workOrderNumbers wo2 ON (wo.WorkOrderNumber = wo2.WorkOrderNumber)
```
#### Business Rules
- Uses table-valued parameter `WorkOrderFilterParameter`
- SELECT * returns all columns from WorkOrder view
- Throws `QueryException` on error
---
### Requirement: LotFinderDB.SearchWorkCenters / LookupWorkCenters component
The system SHALL provide autocomplete and batch lookup for work centers.
#### SearchWorkCenters Query
```sql
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
```
#### LookupWorkCenters Query
```sql
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
```
---
### Requirement: LotFinderDB.SearchProfitCenters / LookupProfitCenters component
The system SHALL provide autocomplete and batch lookup for profit centers.
#### SearchProfitCenters Query
```sql
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
```
#### LookupProfitCenters Query
```sql
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
```
---
### Requirement: LotFinderDB.SearchUsers / LookupUsers component
The system SHALL provide autocomplete and batch lookup for JDE users.
#### SearchUsers Query
```sql
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
```
#### LookupUsers Query
```sql
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
```
#### Business Rules
- SearchUsers: COALESCE UserID to space if null
- LookupUsers: Matches by UserID OR AddressNumber (cast to string)
- Uses table-valued parameter `OperatorFilterParameter`
---
### Requirement: LotFinderDB.LookupLots component
The system SHALL provide batch lookup of lots by lot number and item number.
#### Method Signature
```csharp
Task<List<Lot>> LookupLotsAsync(List<LotViewModel> lots, CancellationToken ct = default)
```
#### Query
```sql
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))
```
#### Business Rules
- Uses table-valued parameter `ComponentLotFilterParameter`
- Matches on LotNumber AND ItemNumber (with NULL handling)
- DISTINCT to eliminate duplicates
---
### Requirement: LotFinderDB.PostProcessMisData component
The system SHALL post-process imported MIS data to set obsolete dates.
#### Method Signature
```csharp
Task PostProcessMisDataAsync(CancellationToken ct = default)
```
#### Query
```sql
SET ANSI_WARNINGS OFF;
-- Set ObsoleteDate for Current status when BackLevel exists
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';
-- Set ObsoleteDate for remaining records based on next revision
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;
```
#### Business Rules
- Two-phase update: first BackLevel, then next revision
- Rebuilds primary key index after updates
- Disables ANSI_WARNINGS for aggregate operations
---
### Requirement: LotFinderDB.GetLastDataUpdates component
The system SHALL retrieve most recent successful update per table/type.
#### Method Signature
```csharp
Task<List<DataUpdate>> GetLastDataUpdatesAsync(CancellationToken ct = default)
```
#### Query
```sql
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
```
#### Business Rules
- Returns latest record per (TableName, UpdateType) combination
- Used to determine incremental update windows
---
### Requirement: LotFinderDB.GetTableSpec component
The system SHALL retrieve table schema for dynamic operations.
#### Method Signature
```csharp
Task<TableSpec> GetTableSpecAsync(string tableName, CancellationToken ct = default)
```
#### Queries
```sql
-- Get columns
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
-- Get primary key columns
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
```
#### Business Rules
- Used for dynamic temp table creation during bulk operations
- Maps SQL Server types to DDL definitions
---
### Requirement: LotFinderDB.RebuildIndices component
The system SHALL rebuild all indices on a table with SQL injection protection.
#### Method Signature
```csharp
Task RebuildIndicesAsync(string tableName, CancellationToken ct = default)
```
#### Query
```sql
ALTER INDEX ALL ON [{tableName}] REBUILD WITH (FILLFACTOR = 95);
```
#### Valid Table Names (Whitelist)
The following table names are valid for index rebuilding:
- `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`
#### Business Rules
- Table name MUST be validated against whitelist before execution
- Throws `ArgumentException` if table name is not in whitelist
- FILLFACTOR = 95 leaves 5% free space for inserts
- Uses `DataAccessOptions.RebuildIndexTimeoutSeconds` for timeout
#### Scenario: Valid table name
- **WHEN** `RebuildIndicesAsync("WorkOrder_Curr")` is called
- **THEN** index rebuild executes successfully because table name is in whitelist
#### Scenario: Invalid table name (SQL injection attempt)
- **WHEN** `RebuildIndicesAsync("WorkOrder]; DROP TABLE Search;--")` is called
- **THEN** `ArgumentException` is thrown with message "Invalid table name"
---
### Requirement: LotFinderDB.GenerateTableParameter component
The system SHALL create DataTable for table-valued parameters.
#### Method Signature
```csharp
DataTable GenerateTableParameter<T>(List<T> keys)
```
#### Business Rules
- Creates single-column DataTable with column named "Key"
- Column type matches generic type T
- Used for simple single-value TVPs
---
## Domain Model Cross-Reference
| Repository Method | Domain Model | Cache Table |
|-------------------|--------------|-------------|
| JDE.GetWorkOrders | WorkOrder | WorkOrder_Curr/WorkOrder_Hist |
| JDE.GetWorkOrderSteps | WorkOrderStep | WorkOrderStep_Curr/WorkOrderStep_Hist |
| JDE.GetWorkOrderTimes | WorkOrderTime | WorkOrderTime_Curr/WorkOrderTime_Hist |
| JDE.GetWorkOrderRoutings | WorkOrderRouting | WorkOrderRouting |
| JDE.GetWorkOrderComponents | WorkOrderComponent | WorkOrderComponent_Curr/WorkOrderComponent_Hist |
| JDE.GetLots | Lot | Lot |
| JDE.GetLotUsages | LotUsage | LotUsage_Curr/LotUsage_Hist |
| JDE.GetLotLocations | LotLocation | LotLocation |
| JDE.GetItems | Item | Item |
| JDE.GetUsers | JdeUser | JdeUser |
| JDE.GetBranches | Branch | Branch |
| JDE.GetProfitCenters | ProfitCenter | ProfitCenter |
| JDE.GetWorkCenters | WorkCenter | WorkCenter |
| JDE.GetStatusCodes | StatusCode | StatusCode |
| JDE.GetFunctionCodes | FunctionCode | FunctionCode |
| JDE.GetOrgHierarchy | OrgHierarchy | OrgHierarchy |
| JDE.GetRouteMasters | RouteMaster | RouteMaster |
| CMS.GetMisData | MisData | MisData |
| LotFinderDB.GetSearch | Search | Search |
| LotFinderDB.GetLastDataUpdates | DataUpdate | DataUpdate |
---
## JDE Table Reference
| JDE Table | Schema | Description |
|-----------|--------|-------------|
| F4801 | PRODDTA / ARCDTAPD | Work Order Master |
| F3112 | PRODDTA / ARCDTAPD | Work Order Routing (Steps) |
| F31122 | PRODDTA / ARCDTAPD | Work Order Time Transactions |
| F3112Z1 | PRODDTA | Work Order Routing Transactions |
| F3111 | PRODDTA / ARCDTAPD | Work Order Parts List (Components) |
| F4108 | PRODDTA | Lot Master |
| F4111 | PRODDTA / ARCDTAPD | Item Ledger (Cardex) |
| F41021 | JDESTAGE | Item Location |
| F4101 | PRODDTA | Item Master |
| F0101 | PRODDTA | Address Book Master |
| F0092 | SY920 | User Profile |
| F0006 | PRODDTA | Business Unit Master |
| F0005 | JDESTAGE | User Defined Codes |
| F00192 | PRODDTA | Media Object (Function Codes) |
| F30006 | PRODDTA | Work Center Master |
| F3003 | PRODDTA | Routing Master |
---
## Migration Notes
| Legacy Pattern | New Pattern | Rationale |
|----------------|-------------|-----------|
| `System.Data.SqlClient` | `Microsoft.Data.SqlClient` | Modern SQL Server client with better performance and security |
| `Oracle.ManagedDataAccess.Client` + `DDTek.Oracle` | `Oracle.ManagedDataAccess.Core` | Consolidated to single Oracle driver for .NET Core/.NET 5+ |
| Static partial classes | Instance-based repositories with interfaces | Testability, dependency injection, loose coupling |
| External .sql files | Embedded resources or `const string` fields | Deployment simplicity, no runtime file I/O |
| `EncryptionHelper.Decrypt` with hardcoded key | Azure Key Vault or .NET Secret Manager | Industry standard secrets management |
| `buffered: false` Dapper | `QueryUnbufferedAsync` with `IAsyncEnumerable<T>` | Native async streaming, memory efficiency |
| `string.Format` SQL (RebuildIndices) | Table name whitelist validation | SQL injection prevention |
| Synchronous methods | Async/await with `CancellationToken` | Scalability, responsiveness, cancellation support |
| NLog static logger | `ILogger<T>` injected + `BeginScope()` for context | Modern structured logging with DI |
| Exception wrapping with generic message | Custom typed exceptions (`DataAccessException`, `ConnectionException`, `QueryException`, `DataAccessTimeoutException`) | Better error handling, debugging, and error classification |
| `Config.*CS` static properties | `IConfiguration["ConnectionStrings:*"]` | Configuration abstraction, environment support |
| Hardcoded timeout constants | `IOptions<DataAccessOptions>` with configurable values | Centralized configuration, runtime flexibility |
| DataTable for TVPs | Keep DataTable (Dapper compatible) | Dapper TVP support, no breaking change needed |
| Return null/empty on error | Throw typed exceptions | Consistent error handling, explicit failure semantics |
---
## Resolved Design Decisions
The following questions from the legacy analysis have been resolved with architectural decisions:
1. **Oracle driver consolidation**: Consolidated to `Oracle.ManagedDataAccess.Core` for all Oracle connections (JDE, JDE Stage, CMS). Single driver simplifies deployment and maintenance.
2. **External SQL files vs embedded resources**: SQL queries stored as embedded resources or compile-time constants. Eliminates runtime file I/O and simplifies deployment.
3. **Password encryption**: Replaced custom `EncryptionHelper.Decrypt` with Azure Key Vault or .NET Secret Manager. Industry-standard approach with proper key management.
4. **Streaming vs buffered**: All large dataset queries use `IAsyncEnumerable<T>` with `QueryUnbufferedAsync`. Streaming pattern preserved for memory efficiency.
5. **SQL injection in RebuildIndices**: Implemented table name whitelist validation. Only explicitly listed table names are allowed.
6. **Error handling inconsistency**: Standardized on throwing typed exceptions. No methods return null/empty on error conditions.
7. **GetUsers incremental bug**: Documented as intentional behavior (full sync always for users). No incremental filtering parameter.
8. **Special timeouts**: Made configurable via `DataAccessOptions`. LotUsage and MisData have separate configurable timeout values.
9. **Schema placeholders**: Schema names (`ProductionSchema`, `ArchiveSchema`, `StageSchema`) are configurable in `DataAccessOptions` for environment-specific deployments.
10. **StatusCode/LotLocation connection**: These queries use `CreateJdeStageConnectionAsync()` for JDESTAGE schema views. Separate connection configuration allows different credentials if needed.
11. **CMS connection visibility**: All connection methods are on `IDbConnectionFactory` interface, providing consistent public access pattern.
12. **Async support**: Full async support with `Task<T>` and `IAsyncEnumerable<T>` return types. All methods accept `CancellationToken`.
13. **Connection pooling**: Connection pooling is configured at the connection string level. `IDbConnectionFactory` opens connections; callers are responsible for disposal.
14. **Transaction support**: Transactions handled at the service layer when needed. Repository methods are single-unit operations.
15. **F41021_VIEW vs F41021**: JDESTAGE views provide flattened/optimized access. Original design preserved; uses JDE Stage connection.
16. **Archive methods**: Explicitly defined as separate interface methods (`GetWorkOrdersArchiveAsync`, etc.) for clarity.
17. **_FILTERED query variants**: Filtered queries use same method with optional `lastUpdateDT` parameter. Null = full sync, non-null = incremental.
---
## Codex Review Findings
The following observations were noted during legacy code analysis:
1. **Mixed driver usage**: Legacy code uses both `Oracle.ManagedDataAccess` and `DDTek.Oracle`. New design consolidates to single driver.
2. **Password decryption location**: Legacy decrypts in `Config.*CS` properties. New design retrieves secrets from secure store at connection time.
3. **LotFinderDB timeout comment**: Legacy comment says "ms" but value is seconds. New design uses explicit `TimeoutSeconds` naming.
4. **Query repository SetQuery unused**: Legacy `SetQuery` method writes to disk but appears unused. Not carried forward to new design.
5. **Inconsistent error patterns**: Some legacy methods return empty/null on error, others throw. New design standardizes on exceptions.