Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
This commit is contained in:
@@ -0,0 +1,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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user