Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,94 @@
# Blazor Client
The `JdeScoping.Client` project is a Blazor WebAssembly application using Radzen Blazor components.
## Project Structure
```
JdeScoping.Client/
├── Program.cs # WASM entry, configure HttpClient + SignalR
├── Pages/
│ ├── Login.razor # LDAP login form
│ ├── Search.razor # Main search criteria page
│ ├── Results.razor # Search history + download
│ └── Admin.razor # Data sync status (optional)
├── Components/
│ ├── SearchCriteriaForm.razor # Complex search form
│ ├── SearchStatusCard.razor # Real-time status display
│ └── LookupDropdown.razor # Autocomplete wrapper
├── Services/
│ ├── SearchApiClient.cs # HTTP calls to SearchController
│ ├── LookupApiClient.cs # HTTP calls to LookupController
│ ├── AuthApiClient.cs # Login/logout
│ └── StatusHubClient.cs # SignalR connection
└── wwwroot/
└── css/ # Custom styles if needed
```
## Radzen Components
Radzen Blazor replaces the legacy Kendo UI JS components. The core library is free (MIT license).
| Component | Usage |
|-----------|-------|
| `RadzenDataGrid` | Search results and history tables |
| `RadzenDropDown` | Work center, operator selection |
| `RadzenAutoComplete` | Item number lookup with search |
| `RadzenDatePicker` | Date range selection |
| `RadzenButton` | Form actions |
| `RadzenCard` | Layout containers |
| `RadzenNotification` | Toast messages |
| `RadzenProgressBar` | Search progress indication |
## Program.cs Configuration
```csharp
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
// HTTP client for API calls
builder.Services.AddScoped(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// API clients
builder.Services.AddScoped<SearchApiClient>();
builder.Services.AddScoped<LookupApiClient>();
builder.Services.AddScoped<AuthApiClient>();
builder.Services.AddScoped<StatusHubClient>();
await builder.Build().RunAsync();
```
## SignalR Client
The `StatusHubClient` connects on login and subscribes to status updates:
```csharp
public class StatusHubClient : IAsyncDisposable
{
private HubConnection _connection;
public event Action<SearchStatusUpdate> OnStatusChanged;
public async Task ConnectAsync(string baseUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl}/hubs/status")
.WithAutomaticReconnect()
.Build();
_connection.On<SearchStatusUpdate>("StatusChanged", update =>
{
OnStatusChanged?.Invoke(update);
});
await _connection.StartAsync();
}
}
```
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Data Flow](./DataFlow.md)
- [Dependencies](./Dependencies.md)
+225
View File
@@ -0,0 +1,225 @@
# Configuration
The application uses standard ASP.NET Core configuration with `appsettings.json` and environment variables for sensitive values.
## appsettings.json Structure
```json
{
"ConnectionStrings": {
"SqlServer": "Server=localhost;Database=LotFinder;Integrated Security=true;TrustServerCertificate=true",
"JdeOracle": "Data Source=jde-server:1521/JDEPROD;User Id=${JDE_USER};Password=${JDE_PASSWORD}",
"CmsOracle": "Data Source=cms-server:1521/CMSPROD;User Id=${CMS_USER};Password=${CMS_PASSWORD}"
},
"DataSource": {
"UseFileDataSource": false,
"FileDirectory": "DevData"
},
"Auth": {
"UseFakeAuth": false
},
"Ldap": {
"Url": "LDAP://your-domain.com",
"BaseDn": "DC=your-domain,DC=com",
"RequiredGroup": "CN=LotFinderUsers,OU=Groups,DC=your-domain,DC=com"
},
"DataSync": {
"MassSchedule": "0 2 * * 0",
"DailySchedule": "0 3 * * *",
"HourlySchedule": "0 * * * *",
"BatchSize": 10000
},
"Search": {
"MaxResultRows": 100000,
"TimeoutSeconds": 300,
"MaxConcurrentSearches": 5
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
## Environment Variables
Sensitive values are provided via environment variables at runtime:
| Variable | Purpose |
|----------|---------|
| `JDE_USER` | JDE Oracle username |
| `JDE_PASSWORD` | JDE Oracle password |
| `CMS_USER` | CMS Oracle username |
| `CMS_PASSWORD` | CMS Oracle password |
For local development, use User Secrets or a `.env` file (not committed to source control).
## Strongly-Typed Options
Configuration sections are bound to strongly-typed options classes:
```csharp
public class LdapOptions
{
public string Url { get; set; }
public string BaseDn { get; set; }
public string RequiredGroup { get; set; }
}
public class DataSyncOptions
{
public string MassSchedule { get; set; }
public string DailySchedule { get; set; }
public string HourlySchedule { get; set; }
public int BatchSize { get; set; } = 10000;
}
public class SearchOptions
{
public int MaxResultRows { get; set; } = 100000;
public int TimeoutSeconds { get; set; } = 300;
public int MaxConcurrentSearches { get; set; } = 5;
}
public class DataSourceOptions
{
public bool UseFileDataSource { get; set; } = false;
public string FileDirectory { get; set; } = "DevData";
}
public class AuthOptions
{
public bool UseFakeAuth { get; set; } = false;
}
```
Registered in `Program.cs`:
```csharp
builder.Services.Configure<LdapOptions>(builder.Configuration.GetSection("Ldap"));
builder.Services.Configure<DataSyncOptions>(builder.Configuration.GetSection("DataSync"));
builder.Services.Configure<SearchOptions>(builder.Configuration.GetSection("Search"));
builder.Services.Configure<DataSourceOptions>(builder.Configuration.GetSection("DataSource"));
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
```
## Data Source Configuration
The JDE and CMS data sources support two implementations:
| Implementation | Use Case |
|----------------|----------|
| Oracle (`JdeOracleDataSource`, `CmsOracleDataSource`) | Production - connects to Oracle databases |
| File (`JdeFileDataSource`, `CmsFileDataSource`) | Development - reads from exported JSON/CSV files |
### Development Setup
For development without Oracle access, set `UseFileDataSource: true` in `appsettings.Development.json`:
```json
{
"DataSource": {
"UseFileDataSource": true,
"FileDirectory": "DevData"
}
}
```
Place data export files in the `DevData` directory:
```
DevData/
├── workorders.json
├── lots.json
├── items.json
└── lotusage.json
```
### Registration Logic
```csharp
var dataSourceOptions = builder.Configuration
.GetSection("DataSource").Get<DataSourceOptions>();
if (dataSourceOptions?.UseFileDataSource == true || builder.Environment.IsDevelopment())
{
builder.Services.AddScoped<IJdeDataSource, JdeFileDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
builder.Services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
```
## Authentication Configuration
Authentication supports two implementations:
| Implementation | Use Case |
|----------------|----------|
| LDAP (`LdapAuthService`) | Production - authenticates against real LDAP server |
| Fake (`FakeAuthService`) | Development - accepts any non-empty credentials |
### Development Setup
For development without LDAP access, set `UseFakeAuth: true` in `appsettings.Development.json`:
```json
{
"Auth": {
"UseFakeAuth": true
}
}
```
The fake auth service:
- Accepts any non-empty username/password combination
- Returns the username as the display name
- Always returns `true` for group membership checks
### Registration Logic
```csharp
var authOptions = builder.Configuration
.GetSection("Auth").Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
builder.Services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
builder.Services.AddScoped<IAuthService, LdapAuthService>();
}
```
## Cron Expressions
Data sync schedules use cron expressions, parsed by the Cronos library:
| Expression | Meaning |
|------------|---------|
| `0 2 * * 0` | Sunday at 2:00 AM |
| `0 3 * * *` | Daily at 3:00 AM |
| `0 * * * *` | Every hour on the hour |
## Windows Service Installation
When installing as a Windows Service, environment variables can be set:
```powershell
# Create service
sc.exe create JdeScopingTool binPath= "C:\Services\JdeScoping\JdeScoping.Host.exe"
# Set environment variables for the service
$envVars = "JDE_USER=myuser`0JDE_PASSWORD=mypass`0CMS_USER=cmsuser`0CMS_PASSWORD=cmspass"
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\JdeScopingTool" -Name "Environment" -Value $envVars
```
## Related Documentation
- [Host Project](./HostProject.md)
- [Data Flow](./DataFlow.md)
+277
View File
@@ -0,0 +1,277 @@
# Core Project
The `JdeScoping.Core` project contains business logic and data access. It has no ASP.NET dependencies and is fully testable.
## Project Structure
```
JdeScoping.Core/
├── Models/
│ ├── Search.cs # Search request + status
│ ├── SearchCriteria.cs # Search parameters
│ ├── SearchStatus.cs # Status enum
│ ├── WorkOrder.cs # Work order entity
│ ├── Lot.cs # Lot entity
│ ├── Item.cs # Item entity
│ └── DataUpdate.cs # Sync tracking
├── Interfaces/
│ ├── ISearchRepository.cs # Local SQL Server cache
│ ├── IJdeDataSource.cs # JDE data access (interface)
│ ├── ICmsDataSource.cs # CMS data access (interface)
│ ├── ISearchService.cs # Search execution
│ └── IExcelExportService.cs # Excel generation
├── Repositories/
│ ├── SearchRepository.cs # Dapper against SQL Server
│ ├── Jde/
│ │ ├── JdeOracleDataSource.cs # Production: Oracle connection
│ │ └── JdeFileDataSource.cs # Development: File-based data
│ └── Cms/
│ ├── CmsOracleDataSource.cs # Production: Oracle connection
│ └── CmsFileDataSource.cs # Development: File-based data
├── Services/
│ ├── SearchService.cs # Search execution logic
│ ├── ExcelExportService.cs # ClosedXML generation
│ └── DataSyncOrchestrator.cs # Sync orchestration logic
└── Auth/
├── IAuthService.cs # Authentication interface
├── LdapAuthService.cs # Production: Real LDAP server
└── FakeAuthService.cs # Development: Accepts any credentials
```
## Repository Pattern
Repositories use Dapper for data access. Connections are created per-query and disposed after use.
```csharp
public class SearchRepository : ISearchRepository
{
private readonly string _connectionString;
public SearchRepository(IConfiguration config)
{
_connectionString = config.GetConnectionString("SqlServer");
}
public async Task<Search> GetByIdAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleOrDefaultAsync<Search>(
"SELECT * FROM Search WHERE Id = @Id", new { Id = id });
}
public async Task<int> CreateAsync(Search search)
{
using var connection = new SqlConnection(_connectionString);
return await connection.ExecuteScalarAsync<int>(
@"INSERT INTO Search (UserId, Criteria, Status, CreatedAt)
VALUES (@UserId, @Criteria, @Status, @CreatedAt);
SELECT SCOPE_IDENTITY();", search);
}
}
```
## Data Source Pattern (JDE/CMS)
JDE and CMS data access uses an interface with two implementations: production (Oracle) and development (file-based). This allows development without Oracle connectivity.
### Interface Definition
```csharp
public interface IJdeDataSource
{
Task<IEnumerable<WorkOrder>> GetWorkOrdersAsync(DateTime since);
Task<IEnumerable<Lot>> GetLotsAsync(DateTime since);
Task<IEnumerable<Item>> GetItemsAsync();
// ... other data retrieval methods
}
public interface ICmsDataSource
{
Task<IEnumerable<LotUsage>> GetLotUsageAsync(DateTime since);
// ... other CMS data methods
}
```
### Production Implementation (Oracle)
```csharp
public class JdeOracleDataSource : IJdeDataSource
{
private readonly string _connectionString;
public JdeOracleDataSource(IConfiguration config)
{
_connectionString = config.GetConnectionString("JdeOracle");
}
public async Task<IEnumerable<WorkOrder>> GetWorkOrdersAsync(DateTime since)
{
using var connection = new OracleConnection(_connectionString);
return await connection.QueryAsync<WorkOrder>(
"SELECT * FROM F4801 WHERE UPMJ >= :Since", new { Since = since });
}
}
```
### Development Implementation (File-based)
```csharp
public class JdeFileDataSource : IJdeDataSource
{
private readonly string _dataDirectory;
public JdeFileDataSource(IConfiguration config)
{
_dataDirectory = config["DataSource:FileDirectory"] ?? "DevData";
}
public async Task<IEnumerable<WorkOrder>> GetWorkOrdersAsync(DateTime since)
{
var filePath = Path.Combine(_dataDirectory, "workorders.json");
var json = await File.ReadAllTextAsync(filePath);
var allOrders = JsonSerializer.Deserialize<List<WorkOrder>>(json);
return allOrders.Where(wo => wo.UpdateDate >= since);
}
}
```
### Registration by Environment
```csharp
// In Program.cs
if (builder.Environment.IsDevelopment())
{
builder.Services.AddScoped<IJdeDataSource, JdeFileDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
builder.Services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
```
This pattern enables:
- Development without Oracle database access
- Testing with predictable data sets
- Easy switching between implementations via configuration
## Authentication Pattern
Authentication uses the same interface pattern with production and development implementations.
### Interface Definition
```csharp
public interface IAuthService
{
Task<AuthResult> AuthenticateAsync(string username, string password);
Task<bool> IsInGroupAsync(string username, string groupName);
}
public class AuthResult
{
public bool Success { get; set; }
public string DisplayName { get; set; }
public string Email { get; set; }
public string ErrorMessage { get; set; }
}
```
### Production Implementation (LDAP)
```csharp
public class LdapAuthService : IAuthService
{
private readonly LdapOptions _options;
public LdapAuthService(IOptions<LdapOptions> options)
{
_options = options.Value;
}
public async Task<AuthResult> AuthenticateAsync(string username, string password)
{
using var connection = new LdapConnection(_options.Url);
try
{
connection.Bind(new NetworkCredential(username, password));
// Retrieve user details from directory
return new AuthResult { Success = true, DisplayName = "..." };
}
catch (LdapException)
{
return new AuthResult { Success = false, ErrorMessage = "Invalid credentials" };
}
}
}
```
### Development Implementation (Fake)
```csharp
public class FakeAuthService : IAuthService
{
private readonly AuthOptions _options;
public FakeAuthService(IOptions<AuthOptions> options)
{
_options = options.Value;
}
public Task<AuthResult> AuthenticateAsync(string username, string password)
{
// Accept any non-empty credentials in development
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
return Task.FromResult(new AuthResult
{
Success = false,
ErrorMessage = "Username and password required"
});
}
return Task.FromResult(new AuthResult
{
Success = true,
DisplayName = username,
Email = $"{username}@dev.local"
});
}
public Task<bool> IsInGroupAsync(string username, string groupName)
{
// Always return true in development
return Task.FromResult(true);
}
}
```
### Registration by Configuration
```csharp
// In Program.cs
var authOptions = builder.Configuration.GetSection("Auth").Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
builder.Services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
builder.Services.AddScoped<IAuthService, LdapAuthService>();
}
```
## Porting Strategy
The legacy Dapper queries in `LotFinderDB*.cs`, `JDE*.cs`, and `CMS*.cs` port with minimal changes:
- Update namespaces (`System.Data.SqlClient` to `Microsoft.Data.SqlClient`)
- Adapt CMS queries from Sybase to Oracle syntax
- Use async/await consistently
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Data Flow](./DataFlow.md)
- [Testing](./Testing.md)
+84
View File
@@ -0,0 +1,84 @@
# Data Flow
The system has two primary data flows: search execution and data synchronization.
## Search Flow
The search flow mirrors the legacy pattern, modernized for ASP.NET Core:
```
1. User submits search via Blazor UI
└─> POST /api/search (SearchCriteria JSON)
2. SearchController validates and stores in SQL Server
└─> Search record created with Status = "Queued"
└─> Returns SearchId to client
3. Client connects to SignalR StatusHub
└─> Subscribes to updates for their SearchId
4. SearchProcessorService (BackgroundService) polls
└─> Finds queued searches
└─> Executes query against local cache
└─> Generates Excel via ClosedXML
└─> Stores result in Search.Results (VARBINARY)
└─> Updates Status = "Complete"
5. StatusHub pushes update to client
└─> Client shows "Complete" status
6. User clicks download
└─> GET /api/search/{id}/download
└─> Returns Excel file stream
```
## Search Status States
| Status | Description |
|--------|-------------|
| `Queued` | Search submitted, waiting for processing |
| `Processing` | Background service is executing the search |
| `Generating` | Query complete, generating Excel file |
| `Complete` | Excel ready for download |
| `Failed` | Error occurred during processing |
## Data Sync Flow
The `DataSyncService` runs on a schedule to keep the local SQL Server cache current:
```
DataSyncService runs on schedule:
├── Mass refresh: Full reload (weekly or manual trigger)
├── Daily refresh: Last 24-48 hours of changes
└── Hourly refresh: Incremental updates
Each sync:
1. Determine tables to sync based on schedule
2. Query JDE/CMS Oracle for changes since last sync
3. Bulk insert/update to SQL Server cache
4. Update DataUpdate table with timestamp
```
## Sync Schedules
| Schedule | Frequency | Scope |
|----------|-----------|-------|
| Mass | Weekly (Sunday 2 AM) or manual | Full reload of all cached tables |
| Daily | Daily (3 AM) | Changes from last 48 hours |
| Hourly | Every hour | Incremental changes since last sync |
The schedules are configured via cron expressions in `appsettings.json` and parsed using the Cronos library.
## Database Connections
| Database | Purpose | Driver |
|----------|---------|--------|
| SQL Server | Local cache, search storage | Microsoft.Data.SqlClient |
| JDE Oracle | Enterprise work order data | Oracle.ManagedDataAccess.Core |
| CMS Oracle | Enterprise CMS data (migrated from Sybase) | Oracle.ManagedDataAccess.Core |
## Related Documentation
- [Host Project](./HostProject.md)
- [Core Project](./CoreProject.md)
- [Configuration](./Configuration.md)
+202
View File
@@ -0,0 +1,202 @@
# Database
The application uses SQL Server for the local cache database. Schema is managed using DbUp, with versioned SQL scripts embedded in the application.
## DbUp Overview
DbUp is a .NET library for deploying changes to SQL Server databases. It tracks which scripts have been executed in a `SchemaVersions` table and runs new scripts in alphabetical order.
Key benefits:
- Schema defined as code (versioned SQL scripts)
- Automatic migration on startup
- Idempotent - safe to run multiple times
- Simple, well-tested library
## Project Structure
```
JdeScoping.Database/
├── JdeScoping.Database.csproj
├── DatabaseMigrator.cs # Entry point for migrations
└── Scripts/
├── 001_CreateSearchTable.sql
├── 002_CreateDataUpdateTable.sql
├── 003_CreateWorkOrderTables.sql
├── 004_CreateLotTables.sql
├── 005_CreateReferenceTables.sql
└── ...
```
## Script Naming Convention
Scripts are named with a numeric prefix for ordering:
```
NNN_DescriptiveName.sql
```
- `NNN`: Zero-padded number (001, 002, etc.)
- `DescriptiveName`: Brief description of what the script does
- Scripts run in alphabetical order (numeric prefix ensures correct order)
## DatabaseMigrator Implementation
```csharp
using DbUp;
using Microsoft.Extensions.Configuration;
namespace JdeScoping.Database;
public class DatabaseMigrator
{
private readonly string _connectionString;
public DatabaseMigrator(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("SqlServer")
?? throw new InvalidOperationException("SqlServer connection string not configured");
}
public DatabaseUpgradeResult Migrate()
{
EnsureDatabase.For.SqlDatabase(_connectionString);
var upgrader = DeployChanges.To
.SqlDatabase(_connectionString)
.WithScriptsEmbeddedInAssembly(typeof(DatabaseMigrator).Assembly)
.WithTransaction()
.LogToConsole()
.Build();
return upgrader.PerformUpgrade();
}
}
```
## Embedding Scripts as Resources
Scripts are embedded in the assembly by configuring the project file:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Scripts\*.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="dbup-sqlserver" Version="5.*" />
</ItemGroup>
</Project>
```
## Running Migrations on Startup
Migrations run early in application startup, before other services are configured:
```csharp
// In Program.cs
var builder = WebApplication.CreateBuilder(args);
// Run database migrations first
var migrator = new DatabaseMigrator(builder.Configuration);
var result = migrator.Migrate();
if (!result.Successful)
{
Console.WriteLine($"Database migration failed: {result.Error}");
return 1;
}
// Continue with normal startup...
builder.Host.UseWindowsService();
```
## Core Tables
The scoping tool cache database includes these primary tables:
| Table | Purpose |
|-------|---------|
| `Search` | User search requests, status, and results (Excel as VARBINARY) |
| `DataUpdate` | Tracks last sync timestamp per data type |
| `WorkOrder_Curr` | Current work orders from JDE |
| `WorkOrder_Hist` | Historical work orders from JDE |
| `LotUsage_Curr` | Current lot usage from CMS |
| `LotUsage_Hist` | Historical lot usage from CMS |
| `Lot` | Lot reference data |
| `Item` | Item master reference data |
| `WorkCenter` | Work center reference data |
| `JdeUser` | Operator reference data |
| `ProfitCenter` | Profit center reference data |
| `SchemaVersions` | DbUp tracking table (auto-created) |
## Example Migration Scripts
### 001_CreateSearchTable.sql
```sql
CREATE TABLE [dbo].[Search] (
[Id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
[UserId] NVARCHAR(50) NOT NULL,
[UserDisplayName] NVARCHAR(100) NULL,
[Criteria] NVARCHAR(MAX) NOT NULL,
[Status] INT NOT NULL DEFAULT 0,
[CreatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
[StartedAt] DATETIME2 NULL,
[CompletedAt] DATETIME2 NULL,
[ResultCount] INT NULL,
[Results] VARBINARY(MAX) NULL,
[ErrorMessage] NVARCHAR(MAX) NULL
);
CREATE INDEX [IX_Search_Status] ON [dbo].[Search] ([Status]);
CREATE INDEX [IX_Search_UserId] ON [dbo].[Search] ([UserId]);
```
### 002_CreateDataUpdateTable.sql
```sql
CREATE TABLE [dbo].[DataUpdate] (
[Id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
[TableName] NVARCHAR(100) NOT NULL,
[UpdateType] NVARCHAR(20) NOT NULL,
[LastUpdated] DATETIME2 NOT NULL,
[RecordCount] INT NULL,
[Status] NVARCHAR(20) NOT NULL DEFAULT 'Completed'
);
CREATE UNIQUE INDEX [IX_DataUpdate_TableName_Type]
ON [dbo].[DataUpdate] ([TableName], [UpdateType]);
```
## Development vs Production
The same migration scripts run in all environments. For development with file-based data sources, the cache tables are still created but populated from JSON/CSV files instead of Oracle.
## Adding New Migrations
1. Create a new SQL file with the next number prefix
2. Write idempotent SQL (use `IF NOT EXISTS` where appropriate)
3. Build and run - DbUp picks up new embedded scripts automatically
```sql
-- Example: 006_AddNewColumn.sql
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.Search') AND name = 'Priority'
)
BEGIN
ALTER TABLE [dbo].[Search] ADD [Priority] INT NOT NULL DEFAULT 0;
END
```
## Related Documentation
- [Overview](./Overview.md)
- [Solution Structure](./SolutionStructure.md)
- [Configuration](./Configuration.md)
- [Data Flow](./DataFlow.md)
@@ -0,0 +1,95 @@
# Package Dependencies
All packages are free with MIT, Apache, or similar permissive licenses.
## JdeScoping.Host
| Package | Purpose | License |
|---------|---------|---------|
| `Microsoft.Extensions.Hosting.WindowsServices` | Windows Service support | MIT |
| `Microsoft.AspNetCore.SignalR` | Real-time updates | MIT |
## JdeScoping.Client
| Package | Purpose | License |
|---------|---------|---------|
| `Microsoft.AspNetCore.Components.WebAssembly` | Blazor WASM runtime | MIT |
| `Microsoft.AspNetCore.Components.WebAssembly.DevServer` | Dev server (dev only) | MIT |
| `Microsoft.AspNetCore.SignalR.Client` | SignalR client | MIT |
| `Radzen.Blazor` | UI components | MIT (free tier) |
## JdeScoping.Core
| Package | Purpose | License |
|---------|---------|---------|
| `Dapper` | Micro-ORM | Apache 2.0 |
| `Microsoft.Data.SqlClient` | SQL Server driver | MIT |
| `Oracle.ManagedDataAccess.Core` | Oracle driver | Oracle Free Use |
| `ClosedXML` | Excel generation | MIT |
| `System.DirectoryServices.Protocols` | LDAP authentication | MIT |
| `Cronos` | Cron expression parsing | MIT |
| `Microsoft.Extensions.Options` | Options pattern | MIT |
| `Microsoft.Extensions.Configuration.Abstractions` | Configuration abstractions | MIT |
## JdeScoping.Database
| Package | Purpose | License |
|---------|---------|---------|
| `dbup-sqlserver` | SQL Server database migrations | MIT |
## JdeScoping.Tests
| Package | Purpose | License |
|---------|---------|---------|
| `xunit` | Test framework | Apache 2.0 |
| `xunit.runner.visualstudio` | VS test runner | Apache 2.0 |
| `Microsoft.NET.Test.Sdk` | Test SDK | MIT |
| `Shouldly` | Assertions | BSD |
| `NSubstitute` | Mocking | BSD |
| `Microsoft.AspNetCore.Mvc.Testing` | Integration tests | MIT |
## Packages Explicitly Avoided
| Package | Reason |
|---------|--------|
| `FluentAssertions` | Commercial license since v6 |
| `EPPlus` (v5+) | Commercial license since v5 |
| `Kendo UI` | Commercial license, replaced by Radzen |
## Version Considerations
- Target **.NET 10** (LTS when released, currently .NET 9 is latest)
- Use latest stable versions of all packages
- `Oracle.ManagedDataAccess.Core` v3.x for .NET 6+ support
- `Radzen.Blazor` v5.x for .NET 8+ Blazor features
## Package Installation
```bash
# Host project
dotnet add src/JdeScoping.Host package Microsoft.Extensions.Hosting.WindowsServices
# Client project
dotnet add src/JdeScoping.Client package Radzen.Blazor
dotnet add src/JdeScoping.Client package Microsoft.AspNetCore.SignalR.Client
# Core project
dotnet add src/JdeScoping.Core package Dapper
dotnet add src/JdeScoping.Core package Microsoft.Data.SqlClient
dotnet add src/JdeScoping.Core package Oracle.ManagedDataAccess.Core
dotnet add src/JdeScoping.Core package ClosedXML
dotnet add src/JdeScoping.Core package Cronos
# Test project
dotnet add tests/JdeScoping.Tests package xunit
dotnet add tests/JdeScoping.Tests package Shouldly
dotnet add tests/JdeScoping.Tests package NSubstitute
# Database project
dotnet add src/JdeScoping.Database package dbup-sqlserver
```
## Related Documentation
- [Overview](./Overview.md)
- [Testing](./Testing.md)
+101
View File
@@ -0,0 +1,101 @@
# Host Project
The `JdeScoping.Host` project is the main entry point - an ASP.NET Core application that runs as a Windows Service.
## Program.cs Configuration
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService(); // Run as Windows Service
// ASP.NET Core services
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddSignalR();
// Background services
builder.Services.AddHostedService<SearchProcessorService>();
builder.Services.AddHostedService<DataSyncService>();
// Core dependencies (from JdeScoping.Core)
builder.Services.AddScoped<ISearchRepository, SearchRepository>();
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<IExcelExportService, ExcelExportService>();
// Data source registration (file-based for dev, Oracle for prod)
var dataSourceOptions = builder.Configuration
.GetSection("DataSource").Get<DataSourceOptions>();
if (dataSourceOptions?.UseFileDataSource == true)
{
builder.Services.AddScoped<IJdeDataSource, JdeFileDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
builder.Services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
// Auth registration (fake for dev, LDAP for prod)
var authOptions = builder.Configuration
.GetSection("Auth").Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
builder.Services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
builder.Services.AddScoped<IAuthService, LdapAuthService>();
}
// Configuration
builder.Services.Configure<LdapOptions>(builder.Configuration.GetSection("Ldap"));
builder.Services.Configure<DataSyncOptions>(builder.Configuration.GetSection("DataSync"));
builder.Services.Configure<DataSourceOptions>(builder.Configuration.GetSection("DataSource"));
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapHub<StatusHub>("/hubs/status");
app.MapFallbackToFile("index.html");
app.Run();
```
## Controllers
| Controller | Purpose |
|------------|---------|
| `SearchController` | Submit search, get results, download Excel |
| `LookupController` | Autocomplete APIs for items, work centers, operators |
| `AuthController` | Login/logout against LDAP |
## Hubs
| Hub | Purpose |
|-----|---------|
| `StatusHub` | Pushes search status updates to connected clients |
## Background Services
| Service | Purpose |
|---------|---------|
| `SearchProcessorService` | Polls for queued searches, executes them, generates Excel |
| `DataSyncService` | Runs on schedule, syncs JDE/CMS data to local cache |
Background services use `IServiceScopeFactory` to create scopes for database access, avoiding scoped-in-singleton issues.
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Data Flow](./DataFlow.md)
- [Configuration](./Configuration.md)
+57
View File
@@ -0,0 +1,57 @@
# Architecture Overview
The JDE Scoping Tool is a manufacturing/ERP search application that caches data from JDE (Oracle) and CMS (Oracle) enterprise systems into SQL Server, allowing users to create complex searches and export results to Excel.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Target framework | .NET 10 | Modern LTS, consolidation from .NET Framework 4.8 |
| Deployment | Self-hosted Kestrel as Windows Service | Simple, no IIS dependency |
| UI | Blazor WebAssembly + Radzen | Modern SPA, free component library |
| Database access | Dapper | Preserve existing queries, minimal changes |
| Oracle driver | Oracle.ManagedDataAccess.Core | Both JDE and CMS now on Oracle |
| Data sources | Interface + prod/dev implementations | Development uses file exports, production uses Oracle |
| Authentication | Interface + prod/dev implementations | Development uses fake auth, production uses LDAP |
| Real-time | ASP.NET Core SignalR | Push search status updates |
| Excel | ClosedXML | Free MIT license (replaces EPPlus) |
| Testing | xUnit + Shouldly + NSubstitute | Free, readable assertions |
| Config | appsettings.json + env vars | Standard, secrets via environment |
| Database migrations | DbUp | Schema defined in application, versioned SQL scripts |
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Windows Service Host │
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │
│ │ Blazor WASM │ │ REST API │ │ SignalR Hub │ │
│ │ Client │ │ Controllers │ │ (StatusHub) │ │
│ └───────────────┘ └───────────────┘ └─────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Background Services │ │
│ │ ┌─────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ SearchProcessor │ │ DataSyncService │ │ │
│ │ └─────────────────┘ └─────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ SQL Server │ │ JDE Oracle │ │ CMS Oracle │
│ (Local Cache)│ │ (Enterprise) │ │ (Enterprise) │
└───────────────┘ └───────────────┘ └───────────────┘
```
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Host Project](./HostProject.md)
- [Blazor Client](./BlazorClient.md)
- [Core Project](./CoreProject.md)
- [Database](./Database.md)
- [Data Flow](./DataFlow.md)
- [Configuration](./Configuration.md)
- [Testing](./Testing.md)
- [Dependencies](./Dependencies.md)
@@ -0,0 +1,71 @@
# Solution Structure
The solution uses a minimal project structure with four projects in a single deployable.
## Project Layout
```
NEW/
├── JdeScoping.sln
├── src/
│ ├── JdeScoping.Host/ # ASP.NET Core host + Blazor server
│ │ ├── Program.cs # Entry point, service configuration
│ │ ├── Controllers/ # API endpoints
│ │ ├── Hubs/ # SignalR hubs
│ │ ├── BackgroundServices/ # Search processor, data sync
│ │ └── wwwroot/ # Blazor WASM published output
│ │
│ ├── JdeScoping.Client/ # Blazor WebAssembly UI
│ │ ├── Pages/ # Razor pages
│ │ ├── Components/ # Reusable Radzen components
│ │ ├── Services/ # HTTP + SignalR clients
│ │ └── wwwroot/ # Static assets
│ │
│ ├── JdeScoping.Core/ # Shared business logic
│ │ ├── Models/ # Domain models (WorkOrder, Search, etc.)
│ │ ├── Interfaces/ # Repository/service contracts
│ │ ├── Repositories/ # Dapper data access
│ │ └── Services/ # Business logic
│ │
│ └── JdeScoping.Database/ # Database schema migrations
│ ├── Scripts/ # Versioned SQL scripts
│ │ ├── 001_CreateSearchTable.sql
│ │ ├── 002_CreateDataUpdateTable.sql
│ │ └── ...
│ └── DatabaseMigrator.cs # DbUp runner
└── tests/
└── JdeScoping.Tests/ # xUnit + Shouldly tests
├── Unit/
└── Integration/
```
## Project Responsibilities
### JdeScoping.Host
The deployable Windows Service. Hosts the Blazor WASM client, REST API, SignalR hub, and background services. References `JdeScoping.Core` for business logic.
### JdeScoping.Client
The Blazor WebAssembly UI. Compiled and published into Host's `wwwroot` folder. Uses Radzen Blazor for components. No direct database access - communicates via HTTP and SignalR.
### JdeScoping.Core
Business logic and data access. Contains domain models, repository interfaces and implementations, and services. No ASP.NET dependencies - fully testable in isolation.
### JdeScoping.Database
Database schema management using DbUp. Contains versioned SQL scripts that are embedded as resources and executed in order on application startup. Scripts are idempotent - DbUp tracks which scripts have run in a `SchemaVersions` table.
### JdeScoping.Tests
Unit and integration tests using xUnit, Shouldly for assertions, and NSubstitute for mocking.
## Related Documentation
- [Overview](./Overview.md)
- [Host Project](./HostProject.md)
- [Blazor Client](./BlazorClient.md)
- [Core Project](./CoreProject.md)
- [Database](./Database.md)
+181
View File
@@ -0,0 +1,181 @@
# Testing Strategy
The test project uses xUnit for the framework, Shouldly for assertions, and NSubstitute for mocking.
## Project Structure
```
JdeScoping.Tests/
├── Unit/
│ ├── Services/
│ │ ├── SearchServiceTests.cs
│ │ ├── ExcelExportServiceTests.cs
│ │ └── DataSyncOrchestratorTests.cs
│ ├── Repositories/
│ │ └── SearchRepositoryTests.cs
│ └── Models/
│ └── SearchCriteriaTests.cs
└── Integration/
├── ApiTests/
│ ├── SearchControllerTests.cs
│ └── LookupControllerTests.cs
└── RepositoryTests/
└── JdeRepositoryTests.cs
```
## Unit Tests
Unit tests mock dependencies and test business logic in isolation:
```csharp
public class SearchServiceTests
{
[Fact]
public async Task ExecuteSearch_WithValidCriteria_ReturnsResults()
{
// Arrange
var mockRepo = Substitute.For<ISearchRepository>();
mockRepo.GetWorkOrdersAsync(Arg.Any<SearchCriteria>())
.Returns(new List<WorkOrder> { new WorkOrder { Number = "WO123" } });
var service = new SearchService(mockRepo);
var criteria = new SearchCriteria { ItemNumber = "ABC123" };
// Act
var results = await service.ExecuteAsync(criteria);
// Assert
results.Count.ShouldBeGreaterThan(0);
results.First().Number.ShouldBe("WO123");
}
[Fact]
public async Task ExecuteSearch_WithInvalidCriteria_ThrowsValidationException()
{
// Arrange
var mockRepo = Substitute.For<ISearchRepository>();
var service = new SearchService(mockRepo);
var criteria = new SearchCriteria(); // Empty criteria
// Act & Assert
await Should.ThrowAsync<ValidationException>(
() => service.ExecuteAsync(criteria));
}
}
```
## Shouldly Assertions
Shouldly provides readable assertion syntax without FluentAssertions licensing:
```csharp
// Value assertions
result.ShouldBe(expected);
result.ShouldNotBeNull();
result.ShouldBeGreaterThan(0);
// Collection assertions
list.ShouldContain(item);
list.ShouldBeEmpty();
list.Count.ShouldBe(5);
// String assertions
text.ShouldStartWith("Error");
text.ShouldContain("expected");
// Exception assertions
Should.Throw<ArgumentException>(() => service.Process(null));
await Should.ThrowAsync<InvalidOperationException>(() => service.ProcessAsync());
```
## NSubstitute Mocking
NSubstitute provides a simple API for creating test doubles:
```csharp
// Create substitute
var mockRepo = Substitute.For<ISearchRepository>();
// Configure returns
mockRepo.GetByIdAsync(123).Returns(new Search { Id = 123 });
mockRepo.GetByIdAsync(Arg.Any<int>()).Returns(x => new Search { Id = (int)x[0] });
// Verify calls
await mockRepo.Received().CreateAsync(Arg.Is<Search>(s => s.Status == "Queued"));
await mockRepo.DidNotReceive().DeleteAsync(Arg.Any<int>());
```
## Integration Tests
Integration tests use `WebApplicationFactory<Program>` for API tests:
```csharp
public class SearchControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public SearchControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task SubmitSearch_ReturnsSearchId()
{
// Arrange
var criteria = new SearchCriteria { ItemNumber = "TEST123" };
var content = new StringContent(
JsonSerializer.Serialize(criteria),
Encoding.UTF8,
"application/json");
// Act
var response = await _client.PostAsync("/api/search", content);
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<SearchResult>();
result.SearchId.ShouldBeGreaterThan(0);
}
}
```
## Database Integration Tests
Repository integration tests run against a local SQL Server instance:
```csharp
public class SearchRepositoryIntegrationTests : IDisposable
{
private readonly string _connectionString;
public SearchRepositoryIntegrationTests()
{
_connectionString = "Server=localhost;Database=LotFinder_Test;...";
// Setup test database
}
[Fact]
public async Task CreateAndRetrieve_RoundTrips()
{
var repo = new SearchRepository(_connectionString);
var search = new Search { UserId = "testuser", Status = "Queued" };
var id = await repo.CreateAsync(search);
var retrieved = await repo.GetByIdAsync(id);
retrieved.ShouldNotBeNull();
retrieved.UserId.ShouldBe("testuser");
}
public void Dispose()
{
// Cleanup test data
}
}
```
## Related Documentation
- [Core Project](./CoreProject.md)
- [Dependencies](./Dependencies.md)