refactor: remove unused classes and consolidate ViewModels in Core

Remove 9 unused types from Core (duplicate extension classes, TableSpec, ColumnSpec, LotLocation), move ComponentLotViewModel and OperatorViewModel from Client to Core, and refactor DataSync.Dev to use pipeline-based configuration. Fix Login.razor to use UserInfoDto directly.
This commit is contained in:
Joseph Doherty
2026-01-19 00:13:12 -05:00
parent 80057590f4
commit 7e36bb4225
89 changed files with 1049 additions and 2282 deletions
@@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Text.Json;
using JdeScoping.Api.Extensions;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ApiContracts.Auth;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
@@ -98,7 +99,8 @@ public class AuthController : ApiControllerBase
new AuthenticationProperties { IsPersistent = false });
_logger.LogInformation("User {Username} logged in successfully", loginModel.Username);
return Ok(new LoginResultModel(true, null, result.User));
var userDto = UserInfoDto.FromUserInfo(result.User!);
return Ok(new LoginResultModel(true, null, userDto));
}
/// <summary>
@@ -122,10 +124,11 @@ public class AuthController : ApiControllerBase
/// <returns>User info on success, 401 if not authenticated</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(UserInfoDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult<UserInfo> GetCurrentUser()
public ActionResult<UserInfoDto> GetCurrentUser()
{
return Ok(CurrentUser);
// CurrentUser is guaranteed non-null due to [Authorize] attribute
return Ok(UserInfoDto.FromUserInfo(CurrentUser!));
}
}
@@ -31,7 +31,7 @@ public partial class FileIOController
try
{
using var stream = file.OpenReadStream();
await using var stream = file.OpenReadStream();
var lotViewModels = _parserService.ParseComponentLots(stream);
var lots = await _repository.LookupLotsAsync(lotViewModels, ct);
@@ -31,7 +31,7 @@ public partial class FileIOController
try
{
using var stream = file.OpenReadStream();
await using var stream = file.OpenReadStream();
var itemNumbers = _parserService.ParseItems(stream);
var items = await _repository.LookupItemsAsync(itemNumbers, ct);
@@ -31,7 +31,7 @@ public partial class FileIOController
try
{
using var stream = file.OpenReadStream();
await using var stream = file.OpenReadStream();
var workOrderNumbers = _parserService.ParseWorkOrders(stream);
var workOrders = await _repository.LookupWorkordersAsync(workOrderNumbers, ct);
@@ -149,10 +149,14 @@ public class PipelineController : ControllerBase
matchCols,
config.Destination.ExcludeFromUpdate?.ToList());
// Mass uses massQuery with no parameters; Daily/Hourly use query with parameters
var parameters = config.Source.Parameters?.Select(p => new PipelineParameterDto(
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? [];
var schedules = new PipelineSchedulesDto(
MapSchedule(config.Schedules?.Mass, defaults.Mass),
MapSchedule(config.Schedules?.Daily, defaults.Daily),
MapSchedule(config.Schedules?.Hourly, defaults.Hourly));
MapSchedule(config.Schedules?.Mass, defaults.Mass, config.Source.MassQuery, [], config.PreScripts, config.PostScripts),
MapSchedule(config.Schedules?.Daily, defaults.Daily, config.Source.Query, parameters, config.PreScripts, config.PostScripts),
MapSchedule(config.Schedules?.Hourly, defaults.Hourly, config.Source.Query, parameters, config.PreScripts, config.PostScripts));
return new PipelineConfigDto(
name,
@@ -167,7 +171,11 @@ public class PipelineController : ControllerBase
private static PipelineScheduleDto MapSchedule(
ScheduleConfig? config,
ScheduleConfig defaults)
ScheduleConfig defaults,
string? query,
List<PipelineParameterDto> parameters,
List<string>? preScripts,
List<string>? postScripts)
{
return new PipelineScheduleDto(
config?.Enabled ?? defaults.Enabled,
@@ -176,7 +184,11 @@ public class PipelineController : ControllerBase
config?.ReIndex ?? defaults.ReIndex,
config?.IntervalMinutes > 0 && config.IntervalMinutes != defaults.IntervalMinutes,
config?.PrePurge != null && config.PrePurge != defaults.PrePurge,
config?.ReIndex != null && config.ReIndex != defaults.ReIndex);
config?.ReIndex != null && config.ReIndex != defaults.ReIndex,
query,
parameters,
preScripts,
postScripts);
}
private static ScheduleConfig? GetScheduleConfig(
+5 -7
View File
@@ -1,6 +1,4 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ApiContracts.SignalR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -11,7 +9,7 @@ namespace JdeScoping.Api.Hubs;
/// </summary>
public class StatusHub : Hub
{
private static StatusUpdate _cachedStatus = new()
private static StatusUpdateDto _cachedStatus = new()
{
Message = "Unknown",
Timestamp = DateTime.UtcNow
@@ -29,7 +27,7 @@ public class StatusHub : Hub
/// Caches the update and broadcasts to all clients.
/// </summary>
/// <param name="statusUpdate">Status update to broadcast</param>
public async Task SetStatus(StatusUpdate statusUpdate)
public async Task SetStatus(StatusUpdateDto statusUpdate)
{
_cachedStatus = statusUpdate;
await Clients.All.SendAsync("statusUpdate", statusUpdate);
@@ -40,7 +38,7 @@ public class StatusHub : Hub
/// Called by clients to get initial cached status on connection.
/// </summary>
/// <returns>The most recent status update</returns>
public StatusUpdate GetCachedStatus()
public StatusUpdateDto GetCachedStatus()
{
return _cachedStatus;
}
@@ -49,7 +47,7 @@ public class StatusHub : Hub
/// Called by controllers/services to broadcast search updates.
/// </summary>
/// <param name="searchUpdate">Search update to broadcast</param>
public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
public async Task PublishSearchUpdate(SearchUpdateDto searchUpdate)
{
await Clients.All.SendAsync("searchUpdate", searchUpdate);
_logger.LogDebug("Search update published: ID={Id}, Status={Status}", searchUpdate.Id, searchUpdate.Status);
@@ -1,6 +1,6 @@
using JdeScoping.Api.Hubs;
using JdeScoping.Core.ApiContracts.SignalR;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -34,7 +34,7 @@ public class SearchNotificationService : ISearchNotificationService
{
try
{
var update = new SearchUpdate(search);
var update = SearchUpdateDto.FromSearch(search);
await _hubContext.Clients.All.SendAsync("searchUpdate", update, ct);
_logger.LogDebug(
"Search update notification sent: Id={SearchId}, Status={Status}",
@@ -56,7 +56,7 @@ public class SearchNotificationService : ISearchNotificationService
{
try
{
var update = new StatusUpdate(status);
var update = StatusUpdateDto.Create(status);
await _hubContext.Clients.All.SendAsync("statusUpdate", update, ct);
_logger.LogDebug("Status notification sent: {Status}", status);
}
@@ -1,6 +1,6 @@
using System.Net.Http.Json;
using System.Security.Claims;
using JdeScoping.Client.Models;
using JdeScoping.Core.ApiContracts.Auth;
using Microsoft.AspNetCore.Components.Authorization;
namespace JdeScoping.Client.Auth;
@@ -46,14 +46,14 @@ public class AuthStateProvider : AuthenticationStateProvider
/// Validates the current session by calling /api/auth/me.
/// Returns null if not authenticated.
/// </summary>
private async Task<UserInfoViewModel?> ValidateSessionAsync()
private async Task<UserInfoDto?> ValidateSessionAsync()
{
try
{
var response = await _httpClient.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<UserInfoViewModel>();
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
}
}
catch
@@ -67,7 +67,7 @@ public class AuthStateProvider : AuthenticationStateProvider
/// <summary>
/// Creates an authenticated state from user info.
/// </summary>
private static AuthenticationState CreateAuthState(UserInfoViewModel user)
private static AuthenticationState CreateAuthState(UserInfoDto user)
{
var claims = new List<Claim>
{
@@ -87,7 +87,7 @@ public class AuthStateProvider : AuthenticationStateProvider
/// <summary>
/// Called after successful login to update auth state.
/// </summary>
public async Task MarkUserAsAuthenticated(UserInfoViewModel user)
public async Task MarkUserAsAuthenticated(UserInfoDto user)
{
await _userStorage.SetUserAsync(user);
NotifyAuthenticationStateChanged(Task.FromResult(CreateAuthState(user)));
@@ -1,4 +1,4 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ApiContracts.Auth;
namespace JdeScoping.Client.Auth;
@@ -12,12 +12,12 @@ public interface IUserStorageService
/// <summary>
/// Gets the stored user info.
/// </summary>
Task<UserInfoViewModel?> GetUserAsync();
Task<UserInfoDto?> GetUserAsync();
/// <summary>
/// Stores the user info.
/// </summary>
Task SetUserAsync(UserInfoViewModel user);
Task SetUserAsync(UserInfoDto user);
/// <summary>
/// Removes the stored user info.
@@ -1,5 +1,5 @@
using System.Text.Json;
using JdeScoping.Client.Models;
using JdeScoping.Core.ApiContracts.Auth;
using Microsoft.JSInterop;
namespace JdeScoping.Client.Auth;
@@ -19,7 +19,7 @@ public class UserStorageService : IUserStorageService
_jsRuntime = jsRuntime;
}
public async Task<UserInfoViewModel?> GetUserAsync()
public async Task<UserInfoDto?> GetUserAsync()
{
try
{
@@ -29,7 +29,7 @@ public class UserStorageService : IUserStorageService
return null;
}
return JsonSerializer.Deserialize<UserInfoViewModel>(json, new JsonSerializerOptions
return JsonSerializer.Deserialize<UserInfoDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
@@ -40,7 +40,7 @@ public class UserStorageService : IUserStorageService
}
}
public async Task SetUserAsync(UserInfoViewModel user)
public async Task SetUserAsync(UserInfoDto user)
{
var json = JsonSerializer.Serialize(user);
await _jsRuntime.InvokeVoidAsync("jdeScopingInterop.setSessionStorage", UserKey, json);
@@ -51,16 +51,42 @@
</tbody>
</table>
@if (!string.IsNullOrWhiteSpace(QueryPreview))
@if (Config.Parameters?.Count > 0)
{
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Parameters</RadzenText>
<ul class="rz-mb-4">
@foreach (var param in Config.Parameters)
{
<li><strong>@param.Name</strong>: @(param.Format ?? "default") (source: @param.Source)</li>
}
</ul>
}
@if (!string.IsNullOrWhiteSpace(Config.Query))
{
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Query</RadzenText>
<div style="background: #f5f5f5; padding: 0.75rem; border-radius: 4px; font-family: monospace; font-size: 0.875rem;">
@QueryPreview
</div>
@if (!string.IsNullOrWhiteSpace(FullQuery))
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">@FormatSql(Config.Query)</pre>
}
@if (Config.PreScripts?.Count > 0)
{
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mt-4 rz-mb-2">Pre-Scripts (@Config.PreScripts.Count)</RadzenText>
@for (int i = 0; i < Config.PreScripts.Count; i++)
{
<RadzenButton Text="View Full Query" Icon="open_in_new" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => OnViewQuery.InvokeAsync(FullQuery))" class="rz-mt-2" />
var script = Config.PreScripts[i];
<RadzenText TextStyle="TextStyle.Body2" class="rz-mb-1"><strong>Script @(i + 1):</strong></RadzenText>
<pre style="background: #fff3cd; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@FormatSql(script)</pre>
}
}
@if (Config.PostScripts?.Count > 0)
{
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mt-4 rz-mb-2">Post-Scripts (@Config.PostScripts.Count)</RadzenText>
@for (int i = 0; i < Config.PostScripts.Count; i++)
{
var script = Config.PostScripts[i];
<RadzenText TextStyle="TextStyle.Body2" class="rz-mb-1"><strong>Script @(i + 1):</strong></RadzenText>
<pre style="background: #d1ecf1; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@FormatSql(script)</pre>
}
}
}
@@ -69,9 +95,6 @@
@code {
[Parameter] public UpdateTypes ScheduleType { get; set; }
[Parameter] public PipelineScheduleDto? Config { get; set; }
[Parameter] public string? QueryPreview { get; set; }
[Parameter] public string? FullQuery { get; set; }
[Parameter] public EventCallback<string> OnViewQuery { get; set; }
private static string GetScheduleTypeName(UpdateTypes type) => type switch
{
@@ -89,4 +112,50 @@
return $"{minutes / 60} hour(s) ({minutes} min)";
return $"{minutes} minutes";
}
private static string FormatSql(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
return "";
// Format SELECT columns - put each column on its own line
var result = FormatSelectColumns(sql);
// Add line breaks before major clauses
result = result
.Replace(" FROM ", "\nFROM ")
.Replace(" WHERE ", "\nWHERE ")
.Replace(" AND ", "\n AND ")
.Replace(" OR ", "\n OR ")
.Replace(" LEFT ", "\nLEFT ")
.Replace(" RIGHT ", "\nRIGHT ")
.Replace(" INNER ", "\nINNER ")
.Replace(" OUTER ", "\nOUTER ")
.Replace(" JOIN ", " JOIN\n ")
.Replace(" ORDER BY ", "\nORDER BY ")
.Replace(" GROUP BY ", "\nGROUP BY ")
.Replace(" HAVING ", "\nHAVING ");
return result.Trim();
}
private static string FormatSelectColumns(string sql)
{
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
return sql;
var beforeSelect = sql[..selectIndex];
var selectKeyword = sql.Substring(selectIndex, 6);
var columnsStart = selectIndex + 6;
var columns = sql[columnsStart..fromIndex];
var afterColumns = sql[fromIndex..];
var columnList = columns.Split(',');
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
}
}
@@ -90,9 +90,11 @@
if (string.IsNullOrWhiteSpace(sql))
return "";
// Basic SQL formatting - add line breaks before major clauses
return sql
.Replace(" SELECT ", "\nSELECT ")
// Format SELECT columns - put each column on its own line
var result = FormatSelectColumns(sql);
// Add line breaks before major clauses
result = result
.Replace(" FROM ", "\nFROM ")
.Replace(" WHERE ", "\nWHERE ")
.Replace(" AND ", "\n AND ")
@@ -104,8 +106,31 @@
.Replace(" JOIN ", " JOIN\n ")
.Replace(" ORDER BY ", "\nORDER BY ")
.Replace(" GROUP BY ", "\nGROUP BY ")
.Replace(" HAVING ", "\nHAVING ")
.Trim();
.Replace(" HAVING ", "\nHAVING ");
return result.Trim();
}
private static string FormatSelectColumns(string sql)
{
// Find SELECT and FROM positions (case-insensitive)
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
return sql;
var beforeSelect = sql[..selectIndex];
var selectKeyword = sql.Substring(selectIndex, 6); // "SELECT"
var columnsStart = selectIndex + 6;
var columns = sql[columnsStart..fromIndex];
var afterColumns = sql[fromIndex..];
// Split columns by comma and rejoin with newlines
var columnList = columns.Split(',');
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
}
private async Task CopyToClipboard()
@@ -1,6 +1,7 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using CoreSearch = JdeScoping.Core.ViewModels.SearchViewModel;
using CoreItem = JdeScoping.Core.ViewModels.ItemViewModel;
using CoreWorkOrder = JdeScoping.Core.ViewModels.WorkOrderViewModel;
@@ -9,6 +10,7 @@ using CoreWorkCenter = JdeScoping.Core.ViewModels.WorkCenterViewModel;
using CoreLot = JdeScoping.Core.ViewModels.LotViewModel;
using CorePartOp = JdeScoping.Core.ViewModels.PartOperationViewModel;
using CoreJdeUser = JdeScoping.Core.ViewModels.JdeUserViewModel;
using SearchViewModel = JdeScoping.Client.Models.SearchViewModel;
namespace JdeScoping.Client.Extensions;
@@ -1,15 +0,0 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// SignalR message for search status updates.
/// </summary>
public record SearchUpdate
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime? SubmitDt { get; init; }
public DateTime? StartDt { get; init; }
public DateTime? EndDt { get; init; }
}
@@ -1,10 +0,0 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// SignalR message for processor status updates.
/// </summary>
public record StatusUpdate
{
public string Message { get; init; } = string.Empty;
public DateTime? Timestamp { get; init; }
}
@@ -1,38 +0,0 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// Client-side view model for authenticated user information.
/// Mirrors the server-side UserInfo model returned by /api/auth/login and /api/auth/me.
/// </summary>
public class UserInfoViewModel
{
/// <summary>
/// User's login username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// User's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// User's last name.
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// User's display name (computed on server, provided here for convenience).
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// User's organization title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// User's email address.
/// </summary>
public string EmailAddress { get; set; } = string.Empty;
}
@@ -6,17 +6,17 @@ namespace JdeScoping.Client.Models;
/// </summary>
public class ValidCombination
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public bool Timespan { get; init; }
public bool WorkOrder { get; init; }
public bool ItemNumber { get; init; }
public bool ProfitCenter { get; init; }
public bool WorkCenter { get; init; }
public bool ComponentLot { get; init; }
public bool Operator { get; init; }
public bool ItemOperationMis { get; init; }
public bool ExtractMis { get; init; }
public int Id { get; private init; }
public string Name { get; private init; } = string.Empty;
public bool Timespan { get; private init; }
public bool WorkOrder { get; private init; }
public bool ItemNumber { get; private init; }
public bool ProfitCenter { get; private init; }
public bool WorkCenter { get; private init; }
public bool ComponentLot { get; private init; }
public bool Operator { get; private init; }
public bool ItemOperationMis { get; private init; }
public bool ExtractMis { get; private init; }
/// <summary>
/// Checks if the given filter flags match this combination.
@@ -48,7 +48,7 @@ public class ValidCombination
/// </summary>
public static IReadOnlyList<ValidCombination> GetAll() =>
[
new ValidCombination
new()
{
Id = 10,
Name = "Work Order",
@@ -62,7 +62,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 20,
Name = "Component Lot",
@@ -76,7 +76,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 30,
Name = "Time Span + Profit Center",
@@ -90,7 +90,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 40,
Name = "Time Span + Work Center",
@@ -104,7 +104,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 50,
Name = "Time Span + Operator",
@@ -118,7 +118,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 60,
Name = "Time Span + Profit Center + Item Number",
@@ -132,7 +132,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 70,
Name = "Time Span + Profit Center + Item/Operation/MIS",
@@ -146,7 +146,7 @@ public class ValidCombination
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 80,
Name = "Time Span + Profit Center + Work Order + Item/Operation/MIS",
@@ -160,7 +160,7 @@ public class ValidCombination
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 90,
Name = "Time Span + Profit Center + Extract MIS",
@@ -174,7 +174,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = true
},
new ValidCombination
new()
{
Id = 100,
Name = "Time Span + Work Center + Item Number",
@@ -188,7 +188,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 110,
Name = "Time Span + Work Center + Extract MIS",
@@ -202,7 +202,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = true
},
new ValidCombination
new()
{
Id = 120,
Name = "Time Span + Work Center + Item/Operation/MIS",
@@ -216,7 +216,7 @@ public class ValidCombination
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 130,
Name = "Time Span + Work Center + Work Order + Item/Operation/MIS",
@@ -230,7 +230,7 @@ public class ValidCombination
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 140,
Name = "Time Span + Item Number",
@@ -244,7 +244,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 150,
Name = "Time Span + Work Center + Operator",
@@ -258,7 +258,7 @@ public class ValidCombination
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
new()
{
Id = 160,
Name = "Time Span + Profit Center + Operator",
@@ -107,7 +107,7 @@ else if (_config is not null)
<!-- Common Pipeline Info -->
<RadzenRow Gap="1rem" class="rz-mb-4">
<!-- Source Card -->
<RadzenColumn Size="4">
<RadzenColumn Size="6">
<RadzenCard Style="height: 100%;">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-2">Source</RadzenText>
<p><strong>Connection:</strong>
@@ -127,21 +127,11 @@ else if (_config is not null)
break;
}
</p>
@if (_config.Source.Parameters.Count > 0)
{
<p><strong>Parameters:</strong></p>
<ul>
@foreach (var param in _config.Source.Parameters)
{
<li>@param.Name (@(param.Format ?? "default"))</li>
}
</ul>
}
</RadzenCard>
</RadzenColumn>
<!-- Destination Card -->
<RadzenColumn Size="4">
<RadzenColumn Size="6">
<RadzenCard Style="height: 100%;">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-2">Destination</RadzenText>
<p><strong>Table:</strong> @_config.Destination.Table</p>
@@ -151,67 +141,36 @@ else if (_config is not null)
</p>
@if (_config.Destination.MatchColumns?.Count > 0)
{
<p><strong>Match Columns:</strong> @string.Join(", ", _config.Destination.MatchColumns)</p>
<p><strong>Match Columns:</strong></p>
<ul>
@foreach (var col in _config.Destination.MatchColumns)
{
<li>@col</li>
}
</ul>
}
@if (_config.Destination.ExcludeFromUpdate?.Count > 0)
{
<p><strong>Exclude:</strong> @string.Join(", ", _config.Destination.ExcludeFromUpdate)</p>
}
</RadzenCard>
</RadzenColumn>
<!-- Scripts Card -->
<RadzenColumn Size="4">
<RadzenCard Style="height: 100%;">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-2">Scripts</RadzenText>
<p><strong>Pre-Scripts:</strong> @_config.PreScriptCount</p>
<p><strong>Post-Scripts:</strong> @_config.PostScriptCount</p>
@if (_config.PreScripts?.Count > 0)
{
<RadzenText TextStyle="TextStyle.Subtitle2" class="rz-mt-2">Pre-Scripts:</RadzenText>
@for (int i = 0; i < _config.PreScripts.Count; i++)
{
var script = _config.PreScripts[i];
var index = i + 1;
<div>
<RadzenButton Text="@($"Script {index}")" Icon="code" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => ShowSqlModal($"Pre-Script {index}", script))" />
</div>
}
}
@if (_config.PostScripts?.Count > 0)
{
<RadzenText TextStyle="TextStyle.Subtitle2" class="rz-mt-2">Post-Scripts:</RadzenText>
@for (int i = 0; i < _config.PostScripts.Count; i++)
{
var script = _config.PostScripts[i];
var index = i + 1;
<div>
<RadzenButton Text="@($"Script {index}")" Icon="code" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => ShowSqlModal($"Post-Script {index}", script))" />
</div>
}
<p><strong>Exclude:</strong></p>
<ul>
@foreach (var col in _config.Destination.ExcludeFromUpdate)
{
<li>@col</li>
}
</ul>
}
</RadzenCard>
</RadzenColumn>
</RadzenRow>
<!-- Schedule Sections -->
<PipelineScheduleSection ScheduleType="UpdateTypes.Mass" Config="@_config.Schedules.Mass"
QueryPreview="@_config.Source.MassQueryPreview" FullQuery="@_config.Source.MassQuery"
OnViewQuery="@(q => ShowSqlModal("Mass Query", q))" />
<PipelineScheduleSection ScheduleType="UpdateTypes.Mass" Config="@_config.Schedules.Mass" />
<PipelineScheduleSection ScheduleType="UpdateTypes.Daily" Config="@_config.Schedules.Daily"
QueryPreview="@_config.Source.QueryPreview" FullQuery="@_config.Source.Query"
OnViewQuery="@(q => ShowSqlModal("Daily Query", q))" />
<PipelineScheduleSection ScheduleType="UpdateTypes.Daily" Config="@_config.Schedules.Daily" />
<PipelineScheduleSection ScheduleType="UpdateTypes.Hourly" Config="@_config.Schedules.Hourly"
QueryPreview="@_config.Source.QueryPreview" FullQuery="@_config.Source.Query"
OnViewQuery="@(q => ShowSqlModal("Hourly Query", q))" />
<PipelineScheduleSection ScheduleType="UpdateTypes.Hourly" Config="@_config.Schedules.Hourly" />
}
<SqlQueryModal @bind-Visible="_showSqlModal" Title="@_sqlModalTitle" Sql="@_sqlModalContent" />
@code {
private List<string> _pipelineNames = [];
private string? _selectedPipeline;
@@ -220,10 +179,6 @@ else if (_config is not null)
private List<PipelineScheduleStatusDto> _statuses = [];
private List<PipelineExecutionDto> _executions = [];
private bool _showSqlModal;
private string? _sqlModalTitle;
private string? _sqlModalContent;
protected override async Task OnInitializedAsync()
{
var result = await PipelineApi.GetPipelineNamesAsync();
@@ -271,13 +226,6 @@ else if (_config is not null)
}
}
private void ShowSqlModal(string title, string sql)
{
_sqlModalTitle = $"{title} - {_selectedPipeline}";
_sqlModalContent = sql;
_showSqlModal = true;
}
private static string FormatDuration(TimeSpan? duration)
{
if (!duration.HasValue) return "-";
+1 -10
View File
@@ -74,16 +74,7 @@
if (loginResult.Success && loginResult.User is not null)
{
// Notify auth state provider of successful login
var userViewModel = new JdeScoping.Client.Models.UserInfoViewModel
{
Username = loginResult.User.Username,
FirstName = loginResult.User.FirstName,
LastName = loginResult.User.LastName,
DisplayName = loginResult.User.DisplayName,
EmailAddress = loginResult.User.EmailAddress,
Title = loginResult.User.Title
};
_ = AuthStateProvider.MarkUserAsAuthenticated(userViewModel);
_ = AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
NavigationManager.NavigateTo(returnUrl);
@@ -315,7 +315,7 @@ else
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdate update)
private void HandleSearchUpdate(SearchUpdateDto update)
{
if (update.Id == _search.Id)
{
@@ -120,7 +120,7 @@ else
}
}
private void HandleSearchUpdate(SearchUpdate update)
private void HandleSearchUpdate(SearchUpdateDto update)
{
InvokeAsync(() =>
{
@@ -162,7 +162,7 @@ else
});
}
private void HandleStatusUpdate(StatusUpdate update)
private void HandleStatusUpdate(StatusUpdateDto update)
{
InvokeAsync(() =>
{
@@ -93,7 +93,7 @@ else
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdate update)
private void HandleSearchUpdate(SearchUpdateDto update)
{
InvokeAsync(() =>
{
@@ -1,6 +1,6 @@
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ApiContracts.Auth;
using JdeScoping.Core.ApiContracts.Results;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
@@ -21,6 +21,6 @@ public class AuthApiClient : ApiClientBase, IAuthApiClient
public Task<ApiResult<Unit>> LogoutAsync(CancellationToken ct = default)
=> PostAsync<Unit>(ApiRoutes.Auth.Logout, ct);
public Task<ApiResult<UserInfo>> GetCurrentUserAsync(CancellationToken ct = default)
=> GetAsync<UserInfo>(ApiRoutes.Auth.Me, ct);
public Task<ApiResult<UserInfoDto>> GetCurrentUserAsync(CancellationToken ct = default)
=> GetAsync<UserInfoDto>(ApiRoutes.Auth.Me, ct);
}
@@ -1,6 +1,5 @@
using System.Net.Http.Json;
using JdeScoping.Client.Auth;
using JdeScoping.Client.Models;
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
@@ -43,17 +42,8 @@ public class AuthService : IAuthService
if (result.Success && result.User is not null)
{
// Notify auth state provider of the login
var userViewModel = new UserInfoViewModel
{
Username = result.User.Username,
FirstName = result.User.FirstName,
LastName = result.User.LastName,
DisplayName = result.User.DisplayName,
EmailAddress = result.User.EmailAddress,
Title = result.User.Title
};
await _authStateProvider.MarkUserAsAuthenticated(userViewModel);
// LoginResultModel.User is already UserInfoDto - pass directly
await _authStateProvider.MarkUserAsAuthenticated(result.User);
}
return result;
@@ -1,4 +1,4 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ApiContracts.SignalR;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
@@ -13,8 +13,8 @@ public class HubConnectionService : IHubConnectionService, IAsyncDisposable
private readonly NavigationManager _navigationManager;
private HubConnection? _hubConnection;
public event Action<SearchUpdate>? OnSearchUpdate;
public event Action<StatusUpdate>? OnStatusUpdate;
public event Action<SearchUpdateDto>? OnSearchUpdate;
public event Action<StatusUpdateDto>? OnStatusUpdate;
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
@@ -43,12 +43,12 @@ public class HubConnectionService : IHubConnectionService, IAsyncDisposable
])
.Build();
_hubConnection.On<SearchUpdate>("searchUpdate", update =>
_hubConnection.On<SearchUpdateDto>("searchUpdate", update =>
{
OnSearchUpdate?.Invoke(update);
});
_hubConnection.On<StatusUpdate>("statusUpdate", update =>
_hubConnection.On<StatusUpdateDto>("statusUpdate", update =>
{
OnStatusUpdate?.Invoke(update);
});
@@ -92,7 +92,7 @@ public class HubConnectionService : IHubConnectionService, IAsyncDisposable
}
}
public async Task<StatusUpdate?> GetCachedStatusAsync()
public async Task<StatusUpdateDto?> GetCachedStatusAsync()
{
if (_hubConnection == null || _hubConnection.State != HubConnectionState.Connected)
{
@@ -101,7 +101,7 @@ public class HubConnectionService : IHubConnectionService, IAsyncDisposable
try
{
return await _hubConnection.InvokeAsync<StatusUpdate>("GetCachedStatus");
return await _hubConnection.InvokeAsync<StatusUpdateDto>("GetCachedStatus");
}
catch (Exception ex)
{
@@ -1,4 +1,4 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ApiContracts.SignalR;
namespace JdeScoping.Client.Services;
@@ -10,12 +10,12 @@ public interface IHubConnectionService
/// <summary>
/// Event fired when a search update is received.
/// </summary>
event Action<SearchUpdate>? OnSearchUpdate;
event Action<SearchUpdateDto>? OnSearchUpdate;
/// <summary>
/// Event fired when a processor status update is received.
/// </summary>
event Action<StatusUpdate>? OnStatusUpdate;
event Action<StatusUpdateDto>? OnStatusUpdate;
/// <summary>
/// Starts the SignalR connection.
@@ -30,7 +30,7 @@ public interface IHubConnectionService
/// <summary>
/// Gets the cached processor status from the server.
/// </summary>
Task<StatusUpdate?> GetCachedStatusAsync();
Task<StatusUpdateDto?> GetCachedStatusAsync();
/// <summary>
/// Gets the current connection state.
+2
View File
@@ -21,5 +21,7 @@
@using JdeScoping.Client.Models
@using JdeScoping.Client.Pages
@using JdeScoping.Client.Services
@using JdeScoping.Core.ApiContracts.Auth
@using JdeScoping.Core.ApiContracts.SignalR
@using JdeScoping.Core.ViewModels
@using ClientSearchViewModel = JdeScoping.Client.Models.SearchViewModel
@@ -0,0 +1,54 @@
using JdeScoping.Core.Models;
namespace JdeScoping.Core.ApiContracts.Auth;
/// <summary>
/// API response DTO for authenticated user information.
/// </summary>
public record UserInfoDto
{
/// <summary>
/// User's login username.
/// </summary>
public string Username { get; init; } = string.Empty;
/// <summary>
/// User's first name.
/// </summary>
public string FirstName { get; init; } = string.Empty;
/// <summary>
/// User's last name.
/// </summary>
public string LastName { get; init; } = string.Empty;
/// <summary>
/// User's display name (computed on server).
/// </summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// User's organization title.
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// User's email address.
/// </summary>
public string EmailAddress { get; init; } = string.Empty;
/// <summary>
/// Creates a UserInfoDto from a UserInfo entity.
/// </summary>
/// <param name="userInfo">The UserInfo entity to convert.</param>
/// <returns>A new UserInfoDto instance.</returns>
public static UserInfoDto FromUserInfo(UserInfo userInfo) => new()
{
Username = userInfo.Username,
FirstName = userInfo.FirstName,
LastName = userInfo.LastName,
DisplayName = userInfo.DisplayName,
Title = userInfo.Title,
EmailAddress = userInfo.EmailAddress
};
}
@@ -1,5 +1,5 @@
using JdeScoping.Core.ApiContracts.Auth;
using JdeScoping.Core.ApiContracts.Results;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Core.ApiContracts;
@@ -19,5 +19,5 @@ public interface IAuthApiClient
Task<ApiResult<Unit>> LogoutAsync(CancellationToken ct = default);
/// <summary>Gets the current authenticated user's information.</summary>
Task<ApiResult<UserInfo>> GetCurrentUserAsync(CancellationToken ct = default);
Task<ApiResult<UserInfoDto>> GetCurrentUserAsync(CancellationToken ct = default);
}
@@ -44,4 +44,8 @@ public record PipelineScheduleDto(
bool ReIndex,
bool IntervalIsOverride,
bool PrePurgeIsOverride,
bool ReIndexIsOverride);
bool ReIndexIsOverride,
string? Query,
List<PipelineParameterDto> Parameters,
List<string>? PreScripts,
List<string>? PostScripts);
@@ -1,6 +1,6 @@
namespace JdeScoping.Core.ApiContracts.Pipelines;
using JdeScoping.Core.Models.Enums;
using Models.Enums;
/// <summary>
/// Pipeline execution history.
@@ -1,6 +1,6 @@
namespace JdeScoping.Core.ApiContracts.Pipelines;
using JdeScoping.Core.Models.Enums;
using Models.Enums;
/// <summary>
/// Pipeline schedule status for each update type.
@@ -0,0 +1,60 @@
using JdeScoping.Core.Models.Search;
namespace JdeScoping.Core.ApiContracts.SignalR;
/// <summary>
/// SignalR message DTO for search status updates.
/// </summary>
public record SearchUpdateDto
{
/// <summary>
/// Search PK ID.
/// </summary>
public int Id { get; init; }
/// <summary>
/// User-friendly name for the search.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// User name of user that submitted the search.
/// </summary>
public string UserName { get; init; } = string.Empty;
/// <summary>
/// Current search status (serialized as string).
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// Timestamp search was submitted.
/// </summary>
public DateTime? SubmitDt { get; init; }
/// <summary>
/// Timestamp search was started.
/// </summary>
public DateTime? StartDt { get; init; }
/// <summary>
/// Timestamp search was completed.
/// </summary>
public DateTime? EndDt { get; init; }
/// <summary>
/// Creates a SearchUpdateDto from a Search entity.
/// </summary>
/// <param name="search">The Search entity to convert.</param>
/// <returns>A new SearchUpdateDto instance.</returns>
public static SearchUpdateDto FromSearch(Search search) => new()
{
Id = search.Id,
Name = search.Name,
UserName = search.UserName,
Status = search.Status.ToString(),
SubmitDt = search.SubmitDt,
StartDt = search.StartDt,
EndDt = search.EndDt
};
}
@@ -0,0 +1,28 @@
namespace JdeScoping.Core.ApiContracts.SignalR;
/// <summary>
/// SignalR message DTO for processor status updates.
/// </summary>
public record StatusUpdateDto
{
/// <summary>
/// Status message to display.
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// Timestamp when message was generated.
/// </summary>
public DateTime? Timestamp { get; init; }
/// <summary>
/// Creates a StatusUpdateDto with the current timestamp.
/// </summary>
/// <param name="message">Status message.</param>
/// <returns>A new StatusUpdateDto instance.</returns>
public static StatusUpdateDto Create(string message) => new()
{
Message = message,
Timestamp = DateTime.UtcNow
};
}
@@ -1,35 +0,0 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Core.Extensions;
/// <summary>
/// Extension methods for Item entity
/// </summary>
public static class ItemExtensions
{
/// <summary>
/// Converts an Item entity to its ViewModel projection
/// </summary>
/// <param name="item">Item entity to convert</param>
/// <returns>ItemViewModel projection</returns>
public static ItemViewModel ToViewModel(this Item item)
{
return new ItemViewModel
{
ItemNumber = item.ItemNumber,
Description = item.Description
};
}
/// <summary>
/// Converts a collection of Item entities to ViewModels
/// </summary>
/// <param name="items">Collection of Item entities</param>
/// <returns>Collection of ItemViewModel projections</returns>
public static IEnumerable<ItemViewModel> ToViewModels(this IEnumerable<Item> items)
{
return items.Select(i => i.ToViewModel());
}
}
@@ -1,36 +0,0 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Core.Extensions;
/// <summary>
/// Extension methods for JdeUser entity
/// </summary>
public static class JdeUserExtensions
{
/// <summary>
/// Converts a JdeUser entity to its ViewModel projection
/// </summary>
/// <param name="jdeUser">JdeUser entity to convert</param>
/// <returns>JdeUserViewModel projection</returns>
public static JdeUserViewModel ToViewModel(this JdeUser jdeUser)
{
return new JdeUserViewModel
{
AddressNumber = jdeUser.AddressNumber,
UserId = jdeUser.UserId,
FullName = jdeUser.FullName
};
}
/// <summary>
/// Converts a collection of JdeUser entities to ViewModels
/// </summary>
/// <param name="jdeUsers">Collection of JdeUser entities</param>
/// <returns>Collection of JdeUserViewModel projections</returns>
public static IEnumerable<JdeUserViewModel> ToViewModels(this IEnumerable<JdeUser> jdeUsers)
{
return jdeUsers.Select(u => u.ToViewModel());
}
}
@@ -1,35 +0,0 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Core.Extensions;
/// <summary>
/// Extension methods for Lot entity
/// </summary>
public static class LotExtensions
{
/// <summary>
/// Converts a Lot entity to its ViewModel projection
/// </summary>
/// <param name="lot">Lot entity to convert</param>
/// <returns>LotViewModel projection</returns>
public static LotViewModel ToViewModel(this Lot lot)
{
return new LotViewModel
{
LotNumber = lot.LotNumber,
ItemNumber = lot.ItemNumber
};
}
/// <summary>
/// Converts a collection of Lot entities to ViewModels
/// </summary>
/// <param name="lots">Collection of Lot entities</param>
/// <returns>Collection of LotViewModel projections</returns>
public static IEnumerable<LotViewModel> ToViewModels(this IEnumerable<Lot> lots)
{
return lots.Select(l => l.ToViewModel());
}
}
@@ -1,35 +0,0 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Core.Extensions;
/// <summary>
/// Extension methods for ProfitCenter entity
/// </summary>
public static class ProfitCenterExtensions
{
/// <summary>
/// Converts a ProfitCenter entity to its ViewModel projection
/// </summary>
/// <param name="profitCenter">ProfitCenter entity to convert</param>
/// <returns>ProfitCenterViewModel projection</returns>
public static ProfitCenterViewModel ToViewModel(this ProfitCenter profitCenter)
{
return new ProfitCenterViewModel
{
Code = profitCenter.Code,
Description = profitCenter.Description
};
}
/// <summary>
/// Converts a collection of ProfitCenter entities to ViewModels
/// </summary>
/// <param name="profitCenters">Collection of ProfitCenter entities</param>
/// <returns>Collection of ProfitCenterViewModel projections</returns>
public static IEnumerable<ProfitCenterViewModel> ToViewModels(this IEnumerable<ProfitCenter> profitCenters)
{
return profitCenters.Select(pc => pc.ToViewModel());
}
}
@@ -1,35 +0,0 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Core.Extensions;
/// <summary>
/// Extension methods for WorkCenter entity
/// </summary>
public static class WorkCenterExtensions
{
/// <summary>
/// Converts a WorkCenter entity to its ViewModel projection
/// </summary>
/// <param name="workCenter">WorkCenter entity to convert</param>
/// <returns>WorkCenterViewModel projection</returns>
public static WorkCenterViewModel ToViewModel(this WorkCenter workCenter)
{
return new WorkCenterViewModel
{
Code = workCenter.Code,
Description = workCenter.Description
};
}
/// <summary>
/// Converts a collection of WorkCenter entities to ViewModels
/// </summary>
/// <param name="workCenters">Collection of WorkCenter entities</param>
/// <returns>Collection of WorkCenterViewModel projections</returns>
public static IEnumerable<WorkCenterViewModel> ToViewModels(this IEnumerable<WorkCenter> workCenters)
{
return workCenters.Select(wc => wc.ToViewModel());
}
}
@@ -1,35 +0,0 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Core.Extensions;
/// <summary>
/// Extension methods for WorkOrder entity
/// </summary>
public static class WorkOrderExtensions
{
/// <summary>
/// Converts a WorkOrder entity to its ViewModel projection
/// </summary>
/// <param name="workOrder">WorkOrder entity to convert</param>
/// <returns>WorkOrderViewModel projection</returns>
public static WorkOrderViewModel ToViewModel(this WorkOrder workOrder)
{
return new WorkOrderViewModel
{
WorkOrderNumber = workOrder.WorkOrderNumber,
ItemNumber = workOrder.ItemNumber
};
}
/// <summary>
/// Converts a collection of WorkOrder entities to ViewModels
/// </summary>
/// <param name="workOrders">Collection of WorkOrder entities</param>
/// <returns>Collection of WorkOrderViewModel projections</returns>
public static IEnumerable<WorkOrderViewModel> ToViewModels(this IEnumerable<WorkOrder> workOrders)
{
return workOrders.Select(wo => wo.ToViewModel());
}
}
@@ -1,4 +1,4 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.ApiContracts.Auth;
namespace JdeScoping.Core.Models.Auth;
@@ -11,4 +11,4 @@ namespace JdeScoping.Core.Models.Auth;
public record LoginResultModel(
bool Success,
string? ErrorMessage,
UserInfo? User);
UserInfoDto? User);
@@ -1,35 +0,0 @@
namespace JdeScoping.Core.Models.Infrastructure;
/// <summary>
/// Database column specification
/// </summary>
public class ColumnSpec
{
/// <summary>
/// Column name
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Column definition (SQL type and constraints)
/// </summary>
public string Definition { get; set; } = string.Empty;
/// <summary>
/// Default constructor
/// </summary>
public ColumnSpec()
{
}
/// <summary>
/// Constructor with name and definition
/// </summary>
/// <param name="name">Column name</param>
/// <param name="definition">Column definition</param>
public ColumnSpec(string name, string definition)
{
Name = name;
Definition = definition;
}
}
@@ -1,83 +0,0 @@
namespace JdeScoping.Core.Models.Infrastructure;
/// <summary>
/// Database table specification for dynamic SQL generation
/// </summary>
public class TableSpec
{
/// <summary>
/// Table name
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Temporary table name (computed as # + Name)
/// </summary>
public string TempTableName => $"#{Name}";
/// <summary>
/// Table columns
/// </summary>
public List<ColumnSpec> Columns { get; set; } = [];
/// <summary>
/// Table columns that form the primary key
/// </summary>
public List<ColumnSpec> PrimaryKey { get; set; } = [];
/// <summary>
/// Default constructor
/// </summary>
public TableSpec()
{
}
/// <summary>
/// Constructor with table name
/// </summary>
/// <param name="name">Table name</param>
public TableSpec(string name)
{
Name = name;
}
/// <summary>
/// Generates SQL for creating an index on the primary key (stub)
/// </summary>
/// <returns>SQL CREATE INDEX statement</returns>
public string GenerateIndex()
{
// Stub implementation - to be expanded based on spec
return string.Empty;
}
/// <summary>
/// Generates SQL for dropping the table (stub)
/// </summary>
/// <returns>SQL DROP TABLE statement</returns>
public string GenerateDrop()
{
// Stub implementation - to be expanded based on spec
return string.Empty;
}
/// <summary>
/// Generates SQL for creating the table (stub)
/// </summary>
/// <returns>SQL CREATE TABLE statement</returns>
public string GenerateCreate()
{
// Stub implementation - to be expanded based on spec
return string.Empty;
}
/// <summary>
/// Gets a column specification by name (stub)
/// </summary>
/// <param name="columnName">Name of column to find</param>
/// <returns>ColumnSpec or null if not found</returns>
public ColumnSpec? GetColumn(string columnName)
{
return Columns.Find(c => c.Name == columnName);
}
}
@@ -1,32 +0,0 @@
namespace JdeScoping.Core.Models.Inventory;
/// <summary>
/// JDE lot location entity
/// </summary>
public class LotLocation
{
/// <summary>
/// Lot unique number
/// </summary>
public string LotNumber { get; set; } = string.Empty;
/// <summary>
/// Short item number
/// </summary>
public long ShortItemNumber { get; set; }
/// <summary>
/// Business unit unique code
/// </summary>
public string BranchCode { get; set; } = string.Empty;
/// <summary>
/// Location code where lot is located
/// </summary>
public string Location { get; set; } = string.Empty;
/// <summary>
/// Timestamp of last update to record
/// </summary>
public DateTime? LastUpdateDt { get; set; }
}
@@ -1,4 +1,4 @@
namespace JdeScoping.Client.Models;
namespace JdeScoping.Core.ViewModels;
/// <summary>
/// View model for component lot filter.
@@ -1,4 +1,4 @@
namespace JdeScoping.Client.Models;
namespace JdeScoping.Core.ViewModels;
/// <summary>
/// View model for operator filter.
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the Branch table.
/// </summary>
public static class BranchDevEtl
{
public static readonly string TableName = "Branch";
public static readonly string CacheFileName = "branch.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -0,0 +1,58 @@
namespace JdeScoping.DataSync.Dev.Configuration;
/// <summary>
/// Root configuration for development ETL pipelines.
/// </summary>
public record DevPipelinesRoot(
DevPipelineSettings? Settings,
Dictionary<string, DevPipelineConfig> Pipelines)
{
public DevPipelineSettings EffectiveSettings => Settings ?? new DevPipelineSettings();
}
/// <summary>
/// Settings for development pipeline execution.
/// </summary>
public record DevPipelineSettings
{
/// <summary>
/// Size categories for parallel/sequential execution.
/// Very large tables run sequentially to avoid IO contention.
/// </summary>
public SizeCategories? SizeCategories { get; init; }
}
/// <summary>
/// Table size categorization for execution strategy.
/// </summary>
public record SizeCategories
{
/// <summary>Small tables (less than 1 MB) - run in parallel.</summary>
public List<string>? Small { get; init; }
/// <summary>Medium tables (1-20 MB) - run in parallel.</summary>
public List<string>? Medium { get; init; }
/// <summary>Large tables (20-200 MB) - run in parallel.</summary>
public List<string>? Large { get; init; }
/// <summary>Very large tables (200+ MB) - run sequentially.</summary>
public List<string>? VeryLarge { get; init; }
}
/// <summary>
/// Configuration for a single development ETL pipeline.
/// </summary>
public record DevPipelineConfig(
DevSourceConfig Source,
DevDestinationConfig Destination);
/// <summary>
/// Source configuration for a development pipeline (file-based).
/// </summary>
public record DevSourceConfig(string FileName);
/// <summary>
/// Destination configuration for a development pipeline.
/// </summary>
public record DevDestinationConfig(string Table);
@@ -0,0 +1,29 @@
using JdeScoping.DataSync.Etl.Pipeline;
namespace JdeScoping.DataSync.Dev.Contracts;
/// <summary>
/// Factory for creating development ETL pipelines from JSON configuration.
/// </summary>
public interface IDevEtlPipelineFactory
{
/// <summary>
/// Gets the list of available table names.
/// </summary>
IEnumerable<string> GetAvailableTables();
/// <summary>
/// Creates a pipeline for the specified table.
/// </summary>
/// <param name="tableName">The table name.</param>
/// <param name="cacheDirectory">The directory containing cache files.</param>
/// <returns>The configured ETL pipeline.</returns>
EtlPipeline GetPipeline(string tableName, string cacheDirectory);
/// <summary>
/// Checks if a table is categorized as very large (should run sequentially).
/// </summary>
/// <param name="tableName">The table name.</param>
/// <returns>True if the table is very large.</returns>
bool IsVeryLargeTable(string tableName);
}
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Contracts;
using JdeScoping.DataSync.Etl.Results;
using Microsoft.Extensions.Logging;
@@ -8,69 +7,20 @@ namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Registry for development ETL pipelines that load from cached protobuf files.
/// Uses JSON configuration via IDevEtlPipelineFactory.
/// </summary>
public class DevEtlRegistry
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly IDevEtlPipelineFactory _pipelineFactory;
private readonly string _cacheDirectory;
private readonly ILogger<DevEtlRegistry>? _logger;
private readonly Dictionary<string, Func<IDbConnectionFactory, string, EtlPipeline>> _pipelineFactories = new(StringComparer.OrdinalIgnoreCase)
{
// Small tables (< 1 MB)
[BranchDevEtl.TableName] = (factory, cacheDir) =>
BranchDevEtl.Create(factory, Path.Combine(cacheDir, BranchDevEtl.CacheFileName)),
[OrgHierarchyDevEtl.TableName] = (factory, cacheDir) =>
OrgHierarchyDevEtl.Create(factory, Path.Combine(cacheDir, OrgHierarchyDevEtl.CacheFileName)),
[WorkCenterDevEtl.TableName] = (factory, cacheDir) =>
WorkCenterDevEtl.Create(factory, Path.Combine(cacheDir, WorkCenterDevEtl.CacheFileName)),
[ProfitCenterDevEtl.TableName] = (factory, cacheDir) =>
ProfitCenterDevEtl.Create(factory, Path.Combine(cacheDir, ProfitCenterDevEtl.CacheFileName)),
// Medium tables (1-20 MB)
[JdeUserDevEtl.TableName] = (factory, cacheDir) =>
JdeUserDevEtl.Create(factory, Path.Combine(cacheDir, JdeUserDevEtl.CacheFileName)),
[FunctionCodeDevEtl.TableName] = (factory, cacheDir) =>
FunctionCodeDevEtl.Create(factory, Path.Combine(cacheDir, FunctionCodeDevEtl.CacheFileName)),
[ItemDevEtl.TableName] = (factory, cacheDir) =>
ItemDevEtl.Create(factory, Path.Combine(cacheDir, ItemDevEtl.CacheFileName)),
[RouteMasterDevEtl.TableName] = (factory, cacheDir) =>
RouteMasterDevEtl.Create(factory, Path.Combine(cacheDir, RouteMasterDevEtl.CacheFileName)),
// Large tables (20-200 MB)
[LotDevEtl.TableName] = (factory, cacheDir) =>
LotDevEtl.Create(factory, Path.Combine(cacheDir, LotDevEtl.CacheFileName)),
[MisDataDevEtl.TableName] = (factory, cacheDir) =>
MisDataDevEtl.Create(factory, Path.Combine(cacheDir, MisDataDevEtl.CacheFileName)),
[WorkOrderCurrDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderCurrDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderCurrDevEtl.CacheFileName)),
[WorkOrderHistDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderHistDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderHistDevEtl.CacheFileName)),
[LotUsageHistDevEtl.TableName] = (factory, cacheDir) =>
LotUsageHistDevEtl.Create(factory, Path.Combine(cacheDir, LotUsageHistDevEtl.CacheFileName)),
[WorkOrderComponentHistDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderComponentHistDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderComponentHistDevEtl.CacheFileName)),
// Very large tables (200+ MB)
[WorkOrderStepHistDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderStepHistDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderStepHistDevEtl.CacheFileName)),
[WorkOrderComponentCurrDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderComponentCurrDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderComponentCurrDevEtl.CacheFileName)),
[WorkOrderRoutingDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderRoutingDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderRoutingDevEtl.CacheFileName)),
[LotUsageCurrDevEtl.TableName] = (factory, cacheDir) =>
LotUsageCurrDevEtl.Create(factory, Path.Combine(cacheDir, LotUsageCurrDevEtl.CacheFileName)),
[WorkOrderStepCurrDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderStepCurrDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderStepCurrDevEtl.CacheFileName)),
[WorkOrderTimeHistDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderTimeHistDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderTimeHistDevEtl.CacheFileName)),
[WorkOrderTimeCurrDevEtl.TableName] = (factory, cacheDir) =>
WorkOrderTimeCurrDevEtl.Create(factory, Path.Combine(cacheDir, WorkOrderTimeCurrDevEtl.CacheFileName)),
};
public DevEtlRegistry(
IDbConnectionFactory connectionFactory,
IDevEtlPipelineFactory pipelineFactory,
string cacheDirectory,
ILogger<DevEtlRegistry>? logger = null)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_pipelineFactory = pipelineFactory ?? throw new ArgumentNullException(nameof(pipelineFactory));
if (string.IsNullOrWhiteSpace(cacheDirectory))
throw new ArgumentException("Cache directory is required.", nameof(cacheDirectory));
@@ -82,21 +32,13 @@ public class DevEtlRegistry
_logger = logger;
}
public IEnumerable<string> GetAvailableTables() => _pipelineFactories.Keys;
public EtlPipeline GetPipeline(string tableName)
{
if (!_pipelineFactories.TryGetValue(tableName, out var factory))
throw new ArgumentException($"No pipeline registered for table '{tableName}'.", nameof(tableName));
return factory(_connectionFactory, _cacheDirectory);
}
public IEnumerable<string> GetAvailableTables() => _pipelineFactory.GetAvailableTables();
public async Task<PipelineResult> RunAsync(string tableName, CancellationToken cancellationToken = default)
{
_logger?.LogInformation("Running dev ETL for {TableName}", tableName);
var pipeline = GetPipeline(tableName);
var pipeline = _pipelineFactory.GetPipeline(tableName, _cacheDirectory);
var result = await pipeline.ExecuteAsync(cancellationToken);
if (result.Success)
@@ -138,10 +80,10 @@ public class DevEtlRegistry
// Separate tables by size - run very large ones sequentially at the end
var smallMediumTables = GetAvailableTables()
.Where(t => !IsVeryLargeTable(t))
.Where(t => !_pipelineFactory.IsVeryLargeTable(t))
.ToList();
var veryLargeTables = GetAvailableTables()
.Where(IsVeryLargeTable)
.Where(t => _pipelineFactory.IsVeryLargeTable(t))
.ToList();
_logger?.LogInformation(
@@ -175,14 +117,4 @@ public class DevEtlRegistry
return results.ToList();
}
/// <summary>
/// Identifies very large tables that should be loaded sequentially to avoid IO contention.
/// </summary>
private static bool IsVeryLargeTable(string tableName) =>
tableName.Contains("WorkOrderTime", StringComparison.OrdinalIgnoreCase) ||
tableName.Contains("WorkOrderStep", StringComparison.OrdinalIgnoreCase) ||
tableName.Contains("WorkOrderRouting", StringComparison.OrdinalIgnoreCase) ||
tableName.Contains("WorkOrderComponent", StringComparison.OrdinalIgnoreCase) ||
tableName.Contains("LotUsage", StringComparison.OrdinalIgnoreCase);
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the FunctionCode table.
/// </summary>
public static class FunctionCodeDevEtl
{
public static readonly string TableName = "FunctionCode";
public static readonly string CacheFileName = "functioncode.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the Item table.
/// </summary>
public static class ItemDevEtl
{
public static readonly string TableName = "Item";
public static readonly string CacheFileName = "item.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -6,6 +6,11 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="JdeScoping.DataSync.Dev.Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="protobuf-net-data" Version="4.1.0" />
</ItemGroup>
@@ -14,4 +19,10 @@
<ProjectReference Include="..\JdeScoping.DataSync\JdeScoping.DataSync.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Pipelines\dev-pipelines.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the JdeUser table.
/// </summary>
public static class JdeUserDevEtl
{
public static readonly string TableName = "JdeUser";
public static readonly string CacheFileName = "jdeuser.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the Lot table.
/// </summary>
public static class LotDevEtl
{
public static readonly string TableName = "Lot";
public static readonly string CacheFileName = "lot.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the LotUsage_Curr table.
/// </summary>
public static class LotUsageCurrDevEtl
{
public static readonly string TableName = "LotUsage_Curr";
public static readonly string CacheFileName = "lotusage_curr.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the LotUsage_Hist table.
/// </summary>
public static class LotUsageHistDevEtl
{
public static readonly string TableName = "LotUsage_Hist";
public static readonly string CacheFileName = "lotusage_hist.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the MisData table.
/// </summary>
public static class MisDataDevEtl
{
public static readonly string TableName = "MisData";
public static readonly string CacheFileName = "misdata.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -0,0 +1,18 @@
namespace JdeScoping.DataSync.Dev.Options;
/// <summary>
/// Configuration options for development ETL pipelines.
/// </summary>
public class DevPipelineOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "DevPipelines";
/// <summary>
/// Path to the dev-pipelines.json configuration file.
/// Relative to the assembly directory.
/// </summary>
public string ConfigPath { get; set; } = "Pipelines/dev-pipelines.json";
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the OrgHierarchy table.
/// </summary>
public static class OrgHierarchyDevEtl
{
public static readonly string TableName = "OrgHierarchy";
public static readonly string CacheFileName = "orghierarchy.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -0,0 +1,96 @@
{
"settings": {
"sizeCategories": {
"small": ["Branch", "OrgHierarchy", "WorkCenter", "ProfitCenter"],
"medium": ["JdeUser", "FunctionCode", "Item", "RouteMaster"],
"large": ["Lot", "MisData", "WorkOrder_Curr", "WorkOrder_Hist", "LotUsage_Hist", "WorkOrderComponent_Hist"],
"veryLarge": ["WorkOrderStep_Hist", "WorkOrderComponent_Curr", "WorkOrderRouting", "LotUsage_Curr", "WorkOrderStep_Curr", "WorkOrderTime_Hist", "WorkOrderTime_Curr"]
}
},
"pipelines": {
"Branch": {
"source": { "fileName": "branch.pb.zstd" },
"destination": { "table": "Branch" }
},
"OrgHierarchy": {
"source": { "fileName": "orghierarchy.pb.zstd" },
"destination": { "table": "OrgHierarchy" }
},
"WorkCenter": {
"source": { "fileName": "workcenter.pb.zstd" },
"destination": { "table": "WorkCenter" }
},
"ProfitCenter": {
"source": { "fileName": "profitcenter.pb.zstd" },
"destination": { "table": "ProfitCenter" }
},
"JdeUser": {
"source": { "fileName": "jdeuser.pb.zstd" },
"destination": { "table": "JdeUser" }
},
"FunctionCode": {
"source": { "fileName": "functioncode.pb.zstd" },
"destination": { "table": "FunctionCode" }
},
"Item": {
"source": { "fileName": "item.pb.zstd" },
"destination": { "table": "Item" }
},
"RouteMaster": {
"source": { "fileName": "routemaster.pb.zstd" },
"destination": { "table": "RouteMaster" }
},
"Lot": {
"source": { "fileName": "lot.pb.zstd" },
"destination": { "table": "Lot" }
},
"MisData": {
"source": { "fileName": "misdata.pb.zstd" },
"destination": { "table": "MisData" }
},
"WorkOrder_Curr": {
"source": { "fileName": "workorder_curr.pb.zstd" },
"destination": { "table": "WorkOrder_Curr" }
},
"WorkOrder_Hist": {
"source": { "fileName": "workorder_hist.pb.zstd" },
"destination": { "table": "WorkOrder_Hist" }
},
"LotUsage_Curr": {
"source": { "fileName": "lotusage_curr.pb.zstd" },
"destination": { "table": "LotUsage_Curr" }
},
"LotUsage_Hist": {
"source": { "fileName": "lotusage_hist.pb.zstd" },
"destination": { "table": "LotUsage_Hist" }
},
"WorkOrderComponent_Curr": {
"source": { "fileName": "workordercomponent_curr.pb.zstd" },
"destination": { "table": "WorkOrderComponent_Curr" }
},
"WorkOrderComponent_Hist": {
"source": { "fileName": "workordercomponent_hist.pb.zstd" },
"destination": { "table": "WorkOrderComponent_Hist" }
},
"WorkOrderStep_Curr": {
"source": { "fileName": "workorderstep_curr.pb.zstd" },
"destination": { "table": "WorkOrderStep_Curr" }
},
"WorkOrderStep_Hist": {
"source": { "fileName": "workorderstep_hist.pb.zstd" },
"destination": { "table": "WorkOrderStep_Hist" }
},
"WorkOrderTime_Curr": {
"source": { "fileName": "workordertime_curr.pb.zstd" },
"destination": { "table": "WorkOrderTime_Curr" }
},
"WorkOrderTime_Hist": {
"source": { "fileName": "workordertime_hist.pb.zstd" },
"destination": { "table": "WorkOrderTime_Hist" }
},
"WorkOrderRouting": {
"source": { "fileName": "workorderrouting.pb.zstd" },
"destination": { "table": "WorkOrderRouting" }
}
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the ProfitCenter table.
/// </summary>
public static class ProfitCenterDevEtl
{
public static readonly string TableName = "ProfitCenter";
public static readonly string CacheFileName = "profitcenter.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the RouteMaster table.
/// </summary>
public static class RouteMasterDevEtl
{
public static readonly string TableName = "RouteMaster";
public static readonly string CacheFileName = "routemaster.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -0,0 +1,123 @@
using System.Text.Json;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev.Configuration;
using JdeScoping.DataSync.Dev.Contracts;
using JdeScoping.DataSync.Dev.Options;
using JdeScoping.DataSync.Dev.Sources;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace JdeScoping.DataSync.Dev.Services;
/// <summary>
/// Factory for creating development ETL pipelines from JSON configuration.
/// </summary>
public class DevEtlPipelineFactory : IDevEtlPipelineFactory
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<EtlPipeline> _logger;
private readonly DevPipelinesRoot _config;
private readonly HashSet<string> _veryLargeTables;
/// <summary>
/// Creates a new development pipeline factory.
/// </summary>
public DevEtlPipelineFactory(
IDbConnectionFactory connectionFactory,
IOptions<DevPipelineOptions> options,
ILogger<EtlPipeline> logger)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_connectionFactory = connectionFactory;
_logger = logger;
_config = LoadPipelineConfigs(options.Value.ConfigPath);
_veryLargeTables = BuildVeryLargeTableSet();
}
/// <summary>
/// Creates a new development pipeline factory with pre-loaded config (for testing).
/// </summary>
internal DevEtlPipelineFactory(
IDbConnectionFactory connectionFactory,
DevPipelinesRoot config,
ILogger<EtlPipeline> logger)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(logger);
_connectionFactory = connectionFactory;
_logger = logger;
_config = config;
_veryLargeTables = BuildVeryLargeTableSet();
}
/// <inheritdoc />
public IEnumerable<string> GetAvailableTables() => _config.Pipelines.Keys;
/// <inheritdoc />
public bool IsVeryLargeTable(string tableName) =>
_veryLargeTables.Contains(tableName);
/// <inheritdoc />
public EtlPipeline GetPipeline(string tableName, string cacheDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
if (!_config.Pipelines.TryGetValue(tableName, out var pipelineConfig))
{
throw new InvalidOperationException(
$"No pipeline configured for table: {tableName}. " +
$"Available tables: {string.Join(", ", _config.Pipelines.Keys)}");
}
var cacheFilePath = Path.Combine(cacheDirectory, pipelineConfig.Source.FileName);
return new EtlPipelineBuilder()
.WithName($"{tableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(_connectionFactory, pipelineConfig.Destination.Table))
.WithLogger(_logger)
.Build();
}
private DevPipelinesRoot LoadPipelineConfigs(string configPath)
{
var assemblyDir = Path.GetDirectoryName(typeof(DevEtlPipelineFactory).Assembly.Location)!;
var fullPath = Path.Combine(assemblyDir, configPath);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException(
$"Dev pipeline config not found: {fullPath}. " +
"Ensure the config file is included in the build output.");
}
var json = File.ReadAllText(fullPath);
var root = JsonSerializer.Deserialize<DevPipelinesRoot>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize dev pipeline config: result was null.");
return root;
}
private HashSet<string> BuildVeryLargeTableSet()
{
var veryLarge = _config.EffectiveSettings.SizeCategories?.VeryLarge;
return veryLarge != null
? new HashSet<string>(veryLarge, StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkCenter table.
/// </summary>
public static class WorkCenterDevEtl
{
public static readonly string TableName = "WorkCenter";
public static readonly string CacheFileName = "workcenter.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderComponent_Curr table.
/// </summary>
public static class WorkOrderComponentCurrDevEtl
{
public static readonly string TableName = "WorkOrderComponent_Curr";
public static readonly string CacheFileName = "workordercomponent_curr.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderComponent_Hist table.
/// </summary>
public static class WorkOrderComponentHistDevEtl
{
public static readonly string TableName = "WorkOrderComponent_Hist";
public static readonly string CacheFileName = "workordercomponent_hist.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrder_Curr table.
/// </summary>
public static class WorkOrderCurrDevEtl
{
public static readonly string TableName = "WorkOrder_Curr";
public static readonly string CacheFileName = "workorder_curr.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrder_Hist table.
/// </summary>
public static class WorkOrderHistDevEtl
{
public static readonly string TableName = "WorkOrder_Hist";
public static readonly string CacheFileName = "workorder_hist.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderRouting table.
/// </summary>
public static class WorkOrderRoutingDevEtl
{
public static readonly string TableName = "WorkOrderRouting";
public static readonly string CacheFileName = "workorderrouting.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderStep_Curr table.
/// </summary>
public static class WorkOrderStepCurrDevEtl
{
public static readonly string TableName = "WorkOrderStep_Curr";
public static readonly string CacheFileName = "workorderstep_curr.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderStep_Hist table.
/// </summary>
public static class WorkOrderStepHistDevEtl
{
public static readonly string TableName = "WorkOrderStep_Hist";
public static readonly string CacheFileName = "workorderstep_hist.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderTime_Curr table.
/// </summary>
public static class WorkOrderTimeCurrDevEtl
{
public static readonly string TableName = "WorkOrderTime_Curr";
public static readonly string CacheFileName = "workordertime_curr.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -1,29 +0,0 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Etl.Destinations;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Dev.Sources;
namespace JdeScoping.DataSync.Dev;
/// <summary>
/// Development ETL pipeline for the WorkOrderTime_Hist table.
/// </summary>
public static class WorkOrderTimeHistDevEtl
{
public static readonly string TableName = "WorkOrderTime_Hist";
public static readonly string CacheFileName = "workordertime_hist.pb.zstd";
public static EtlPipeline Create(IDbConnectionFactory connectionFactory, string cacheFilePath)
{
ArgumentNullException.ThrowIfNull(connectionFactory);
if (string.IsNullOrWhiteSpace(cacheFilePath))
throw new ArgumentException("Cache file path is required.", nameof(cacheFilePath));
return new EtlPipelineBuilder()
.WithName($"{TableName}_Dev")
.WithSource(new ProtobufZstdFileSource(cacheFilePath))
.WithDestination(new DbBulkImportDestination(connectionFactory, TableName))
.Build();
}
}
@@ -113,7 +113,7 @@ public sealed class FluentTableWriter
else if (column.AutoWidth)
{
worksheet.Column(col).AdjustToContents();
worksheet.Column(col).Width *= Formatting.ExcelFormats.DataPaddingFactor;
worksheet.Column(col).Width *= ExcelFormats.DataPaddingFactor;
}
else
{
@@ -4,6 +4,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JdeScoping.Api.Controllers;
using JdeScoping.Core.ApiContracts.Auth;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
@@ -180,7 +181,7 @@ public class AuthControllerTests
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var user = okResult.Value.ShouldBeOfType<UserInfo>();
var user = okResult.Value.ShouldBeOfType<UserInfoDto>();
user.Username.ShouldBe("testuser");
}
@@ -1,8 +1,5 @@
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ApiContracts.SignalR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -36,7 +33,7 @@ public class StatusHubTests
var clientsProperty = typeof(Hub).GetProperty("Clients");
clientsProperty?.SetValue(_hub, hubClients);
var statusUpdate = new StatusUpdate
var statusUpdate = new StatusUpdateDto
{
Message = "Processing",
Timestamp = DateTime.UtcNow
@@ -93,13 +90,12 @@ public class StatusHubTests
var clientsProperty = typeof(Hub).GetProperty("Clients");
clientsProperty?.SetValue(_hub, hubClients);
var searchUpdate = new SearchUpdate
var searchUpdate = new SearchUpdateDto
{
Id = 42,
UserName = "testuser",
Name = "Test Search",
Status = SearchStatus.Running,
Timestamp = DateTime.UtcNow
Status = "Running"
};
// Act
@@ -2,8 +2,8 @@ using System.Net;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ApiContracts.Auth;
using JdeScoping.Core.ApiContracts.Results;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
using RichardSzalay.MockHttp;
using Shouldly;
@@ -70,7 +70,7 @@ public class AuthApiClientTests
public async Task GetCurrentUserAsync_CallsCorrectRoute()
{
// Arrange
var user = new UserInfo { Username = "testuser", FirstName = "Test", LastName = "User" };
var user = new UserInfoDto { Username = "testuser", FirstName = "Test", LastName = "User" };
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Auth.Me}")
.Respond("application/json", JsonSerializer.Serialize(user));
@@ -103,7 +103,7 @@ public class AuthApiClientTests
public async Task LoginAsync_Success_ReturnsLoginResult()
{
// Arrange
var user = new UserInfo { Username = "testuser", FirstName = "Test", LastName = "User" };
var user = new UserInfoDto { Username = "testuser", FirstName = "Test", LastName = "User" };
var loginResult = new LoginResultModel(true, null, user);
_mockHttp.When(HttpMethod.Post, "*")
.Respond("application/json", JsonSerializer.Serialize(loginResult));
@@ -135,7 +135,7 @@ public class AuthApiClientTests
public async Task GetCurrentUserAsync_Success_ReturnsUserInfo()
{
// Arrange
var userInfo = new UserInfo { Username = "testuser", FirstName = "Test", LastName = "User" };
var userInfo = new UserInfoDto { Username = "testuser", FirstName = "Test", LastName = "User" };
_mockHttp.When(HttpMethod.Get, "*")
.Respond("application/json", JsonSerializer.Serialize(userInfo));
@@ -1,177 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for Branch development ETL.
/// Requires: Local SQL Server, CACHED_DB_FILES directory with branch.json.zstd
/// </summary>
public class BranchDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public BranchDevEtlTests()
{
// Load configuration
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
// Ensure Branch table is empty before test
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.Branch");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
// Arrange
var cacheFilePath = Path.Combine(_cacheDirectory, BranchDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath))
{
// Skip test if cache file doesn't exist
return;
}
// Act
var pipeline = BranchDevEtl.Create(_connectionFactory, cacheFilePath);
// Assert
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("Branch_Dev");
}
[Fact]
public async Task Execute_LoadsBranchData()
{
// Arrange
var cacheFilePath = Path.Combine(_cacheDirectory, BranchDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath))
{
// Skip test if cache file doesn't exist
return;
}
var pipeline = BranchDevEtl.Create(_connectionFactory, cacheFilePath);
// Act
var result = await pipeline.ExecuteAsync();
// Assert
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0, "Should load at least one row");
// Verify data in database
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.Branch");
count.ShouldBe((int)result.TotalRows, "Database row count should match pipeline result");
}
[Fact]
public async Task Registry_RunAsync_LoadsBranch()
{
// Arrange
if (!Directory.Exists(_cacheDirectory))
{
// Skip test if cache directory doesn't exist
return;
}
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
// Act
var result = await registry.RunAsync("Branch");
// Assert
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
// Arrange
var cacheFilePath = Path.Combine(_cacheDirectory, BranchDevEtl.CacheFileName);
// Act & Assert
Should.Throw<ArgumentNullException>(() => BranchDevEtl.Create(null!, cacheFilePath));
}
[Fact]
public void Create_WithEmptyCacheFilePath_ThrowsArgumentException()
{
// Arrange
var mockFactory = Substitute.For<IDbConnectionFactory>();
// Act & Assert
Should.Throw<ArgumentException>(() => BranchDevEtl.Create(mockFactory, string.Empty));
}
[Fact]
public void Create_WithNonExistentCacheFile_ThrowsFileNotFoundException()
{
// Arrange
var mockFactory = Substitute.For<IDbConnectionFactory>();
var nonExistentPath = "/nonexistent/path/branch.json.zstd";
// Act & Assert
Should.Throw<FileNotFoundException>(() => BranchDevEtl.Create(mockFactory, nonExistentPath));
}
[Fact]
public void DevEtlRegistry_WithNonExistentCacheDirectory_ThrowsDirectoryNotFoundException()
{
// Arrange
var mockFactory = Substitute.For<IDbConnectionFactory>();
var nonExistentPath = "/nonexistent/cache/directory";
// Act & Assert
Should.Throw<DirectoryNotFoundException>(() => new DevEtlRegistry(mockFactory, nonExistentPath));
}
[Fact]
public void DevEtlRegistry_GetAvailableTables_IncludesBranch()
{
// Arrange
if (!Directory.Exists(_cacheDirectory))
{
return;
}
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
// Act
var tables = registry.GetAvailableTables().ToList();
// Assert
tables.ShouldContain("Branch");
}
}
@@ -0,0 +1,210 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev.Configuration;
using JdeScoping.DataSync.Dev.Contracts;
using JdeScoping.DataSync.Dev.Options;
using JdeScoping.DataSync.Dev.Services;
using JdeScoping.DataSync.Etl.Pipeline;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Tests for DevEtlPipelineFactory.
/// </summary>
public class DevEtlPipelineFactoryTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<EtlPipeline> _logger;
private readonly string _cacheDirectory;
public DevEtlPipelineFactoryTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
_logger = NullLogger<EtlPipeline>.Instance;
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
}
[Fact]
public void Constructor_WithValidConfig_LoadsPipelines()
{
// Arrange
var pipelines = new Dictionary<string, DevPipelineConfig>
{
["TestTable"] = new(new DevSourceConfig("test.pb.zstd"), new DevDestinationConfig("TestTable"))
};
var config = new DevPipelinesRoot(null, pipelines);
// Act
var factory = new DevEtlPipelineFactory(_connectionFactory, config, _logger);
// Assert
factory.GetAvailableTables().ShouldContain("TestTable");
}
[Fact]
public void GetAvailableTables_Returns21Tables()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Act
var tables = factory.GetAvailableTables().ToList();
// Assert
tables.Count.ShouldBe(21);
tables.ShouldContain("Branch");
tables.ShouldContain("WorkOrder_Curr");
tables.ShouldContain("LotUsage_Curr");
}
[Fact]
public void IsVeryLargeTable_ReturnsTrueForVeryLargeTables()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Act & Assert
factory.IsVeryLargeTable("WorkOrderTime_Curr").ShouldBeTrue();
factory.IsVeryLargeTable("WorkOrderStep_Curr").ShouldBeTrue();
factory.IsVeryLargeTable("LotUsage_Curr").ShouldBeTrue();
}
[Fact]
public void IsVeryLargeTable_ReturnsFalseForSmallTables()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Act & Assert
factory.IsVeryLargeTable("Branch").ShouldBeFalse();
factory.IsVeryLargeTable("Item").ShouldBeFalse();
factory.IsVeryLargeTable("JdeUser").ShouldBeFalse();
}
[Fact]
public void GetPipeline_WithValidTable_ReturnsPipeline()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Skip if cache directory doesn't exist
if (!Directory.Exists(_cacheDirectory))
return;
var cacheFilePath = Path.Combine(_cacheDirectory, "branch.pb.zstd");
if (!File.Exists(cacheFilePath))
return;
// Act
var pipeline = factory.GetPipeline("Branch", _cacheDirectory);
// Assert
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("Branch_Dev");
}
[Fact]
public void GetPipeline_WithInvalidTable_ThrowsInvalidOperationException()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Act & Assert
Should.Throw<InvalidOperationException>(() => factory.GetPipeline("NonExistentTable", _cacheDirectory));
}
[Fact]
public void GetPipeline_WithNullTableName_ThrowsArgumentException()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Act & Assert
Should.Throw<ArgumentException>(() => factory.GetPipeline(null!, _cacheDirectory));
}
[Fact]
public void GetPipeline_WithEmptyCacheDirectory_ThrowsArgumentException()
{
// Arrange
var factory = CreateFactoryFromConfig();
// Act & Assert
Should.Throw<ArgumentException>(() => factory.GetPipeline("Branch", string.Empty));
}
[Fact]
public void Constructor_WithNullConnectionFactory_ThrowsArgumentNullException()
{
// Arrange
var config = new DevPipelinesRoot(null, new Dictionary<string, DevPipelineConfig>());
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DevEtlPipelineFactory(null!, config, _logger));
}
[Fact]
public void Constructor_WithNullConfig_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DevEtlPipelineFactory(_connectionFactory, (DevPipelinesRoot)null!, _logger));
}
private DevEtlPipelineFactory CreateFactoryFromConfig()
{
// Load actual config from JSON file
var settings = new DevPipelineSettings
{
SizeCategories = new SizeCategories
{
Small = ["Branch", "OrgHierarchy", "WorkCenter", "ProfitCenter"],
Medium = ["JdeUser", "FunctionCode", "Item", "RouteMaster"],
Large = ["Lot", "MisData", "WorkOrder_Curr", "WorkOrder_Hist", "LotUsage_Hist", "WorkOrderComponent_Hist"],
VeryLarge = ["WorkOrderStep_Hist", "WorkOrderComponent_Curr", "WorkOrderRouting", "LotUsage_Curr", "WorkOrderStep_Curr", "WorkOrderTime_Hist", "WorkOrderTime_Curr"]
}
};
var pipelines = new Dictionary<string, DevPipelineConfig>
{
["Branch"] = new(new DevSourceConfig("branch.pb.zstd"), new DevDestinationConfig("Branch")),
["OrgHierarchy"] = new(new DevSourceConfig("orghierarchy.pb.zstd"), new DevDestinationConfig("OrgHierarchy")),
["WorkCenter"] = new(new DevSourceConfig("workcenter.pb.zstd"), new DevDestinationConfig("WorkCenter")),
["ProfitCenter"] = new(new DevSourceConfig("profitcenter.pb.zstd"), new DevDestinationConfig("ProfitCenter")),
["JdeUser"] = new(new DevSourceConfig("jdeuser.pb.zstd"), new DevDestinationConfig("JdeUser")),
["FunctionCode"] = new(new DevSourceConfig("functioncode.pb.zstd"), new DevDestinationConfig("FunctionCode")),
["Item"] = new(new DevSourceConfig("item.pb.zstd"), new DevDestinationConfig("Item")),
["RouteMaster"] = new(new DevSourceConfig("routemaster.pb.zstd"), new DevDestinationConfig("RouteMaster")),
["Lot"] = new(new DevSourceConfig("lot.pb.zstd"), new DevDestinationConfig("Lot")),
["MisData"] = new(new DevSourceConfig("misdata.pb.zstd"), new DevDestinationConfig("MisData")),
["WorkOrder_Curr"] = new(new DevSourceConfig("workorder_curr.pb.zstd"), new DevDestinationConfig("WorkOrder_Curr")),
["WorkOrder_Hist"] = new(new DevSourceConfig("workorder_hist.pb.zstd"), new DevDestinationConfig("WorkOrder_Hist")),
["LotUsage_Curr"] = new(new DevSourceConfig("lotusage_curr.pb.zstd"), new DevDestinationConfig("LotUsage_Curr")),
["LotUsage_Hist"] = new(new DevSourceConfig("lotusage_hist.pb.zstd"), new DevDestinationConfig("LotUsage_Hist")),
["WorkOrderComponent_Curr"] = new(new DevSourceConfig("workordercomponent_curr.pb.zstd"), new DevDestinationConfig("WorkOrderComponent_Curr")),
["WorkOrderComponent_Hist"] = new(new DevSourceConfig("workordercomponent_hist.pb.zstd"), new DevDestinationConfig("WorkOrderComponent_Hist")),
["WorkOrderStep_Curr"] = new(new DevSourceConfig("workorderstep_curr.pb.zstd"), new DevDestinationConfig("WorkOrderStep_Curr")),
["WorkOrderStep_Hist"] = new(new DevSourceConfig("workorderstep_hist.pb.zstd"), new DevDestinationConfig("WorkOrderStep_Hist")),
["WorkOrderTime_Curr"] = new(new DevSourceConfig("workordertime_curr.pb.zstd"), new DevDestinationConfig("WorkOrderTime_Curr")),
["WorkOrderTime_Hist"] = new(new DevSourceConfig("workordertime_hist.pb.zstd"), new DevDestinationConfig("WorkOrderTime_Hist")),
["WorkOrderRouting"] = new(new DevSourceConfig("workorderrouting.pb.zstd"), new DevDestinationConfig("WorkOrderRouting"))
};
var config = new DevPipelinesRoot(settings, pipelines);
return new DevEtlPipelineFactory(_connectionFactory, config, _logger);
}
}
@@ -0,0 +1,101 @@
using JdeScoping.DataSync.Dev.Configuration;
using JdeScoping.DataSync.Dev.Contracts;
using JdeScoping.DataSync.Dev.Services;
using JdeScoping.DataSync.Etl.Pipeline;
using JdeScoping.DataSync.Etl.Results;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Tests for DevEtlRegistry.
/// </summary>
public class DevEtlRegistryTests : IDisposable
{
private readonly IDevEtlPipelineFactory _mockFactory;
private readonly ILogger<DevEtlRegistry> _logger;
private readonly string _tempDirectory;
public DevEtlRegistryTests()
{
_mockFactory = Substitute.For<IDevEtlPipelineFactory>();
_logger = NullLogger<DevEtlRegistry>.Instance;
// Create a temp directory for tests
_tempDirectory = Path.Combine(Path.GetTempPath(), $"DevEtlRegistryTests_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDirectory);
}
[Fact]
public void Constructor_WithValidArguments_Succeeds()
{
// Arrange
_mockFactory.GetAvailableTables().Returns(new[] { "Branch" });
// Act
var registry = new DevEtlRegistry(_mockFactory, _tempDirectory, _logger);
// Assert
registry.ShouldNotBeNull();
}
[Fact]
public void Constructor_WithNullFactory_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() => new DevEtlRegistry(null!, _tempDirectory, _logger));
}
[Fact]
public void Constructor_WithEmptyCacheDirectory_ThrowsArgumentException()
{
// Act & Assert
Should.Throw<ArgumentException>(() => new DevEtlRegistry(_mockFactory, string.Empty, _logger));
}
[Fact]
public void Constructor_WithNonExistentCacheDirectory_ThrowsDirectoryNotFoundException()
{
// Arrange
var nonExistentPath = "/nonexistent/cache/directory";
// Act & Assert
Should.Throw<DirectoryNotFoundException>(() => new DevEtlRegistry(_mockFactory, nonExistentPath, _logger));
}
[Fact]
public void GetAvailableTables_DelegatesToFactory()
{
// Arrange
var expectedTables = new[] { "Branch", "Item", "Lot" };
_mockFactory.GetAvailableTables().Returns(expectedTables);
var registry = new DevEtlRegistry(_mockFactory, _tempDirectory, _logger);
// Act
var tables = registry.GetAvailableTables().ToList();
// Assert
tables.ShouldBe(expectedTables);
_mockFactory.Received(1).GetAvailableTables();
}
public void Dispose()
{
// Cleanup temp directory
if (Directory.Exists(_tempDirectory))
{
try
{
Directory.Delete(_tempDirectory, recursive: true);
}
catch
{
// Ignore cleanup errors
}
}
}
}
@@ -1,96 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for FunctionCode development ETL.
/// </summary>
public class FunctionCodeDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public FunctionCodeDevEtlTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.FunctionCode");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
var cacheFilePath = Path.Combine(_cacheDirectory, FunctionCodeDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = FunctionCodeDevEtl.Create(_connectionFactory, cacheFilePath);
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("FunctionCode_Dev");
}
[Fact]
public async Task Execute_LoadsData()
{
var cacheFilePath = Path.Combine(_cacheDirectory, FunctionCodeDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = FunctionCodeDevEtl.Create(_connectionFactory, cacheFilePath);
var result = await pipeline.ExecuteAsync();
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.FunctionCode");
count.ShouldBe((int)result.TotalRows);
}
[Fact]
public async Task Registry_RunAsync_LoadsTable()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var result = await registry.RunAsync("FunctionCode");
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
var cacheFilePath = Path.Combine(_cacheDirectory, FunctionCodeDevEtl.CacheFileName);
Should.Throw<ArgumentNullException>(() => FunctionCodeDevEtl.Create(null!, cacheFilePath));
}
}
@@ -1,96 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for Item development ETL.
/// </summary>
public class ItemDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public ItemDevEtlTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.Item");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
var cacheFilePath = Path.Combine(_cacheDirectory, ItemDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = ItemDevEtl.Create(_connectionFactory, cacheFilePath);
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("Item_Dev");
}
[Fact]
public async Task Execute_LoadsData()
{
var cacheFilePath = Path.Combine(_cacheDirectory, ItemDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = ItemDevEtl.Create(_connectionFactory, cacheFilePath);
var result = await pipeline.ExecuteAsync();
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.Item");
count.ShouldBe((int)result.TotalRows);
}
[Fact]
public async Task Registry_RunAsync_LoadsTable()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var result = await registry.RunAsync("Item");
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
var cacheFilePath = Path.Combine(_cacheDirectory, ItemDevEtl.CacheFileName);
Should.Throw<ArgumentNullException>(() => ItemDevEtl.Create(null!, cacheFilePath));
}
}
@@ -1,96 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for JdeUser development ETL.
/// </summary>
public class JdeUserDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public JdeUserDevEtlTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.JdeUser");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
var cacheFilePath = Path.Combine(_cacheDirectory, JdeUserDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = JdeUserDevEtl.Create(_connectionFactory, cacheFilePath);
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("JdeUser_Dev");
}
[Fact]
public async Task Execute_LoadsData()
{
var cacheFilePath = Path.Combine(_cacheDirectory, JdeUserDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = JdeUserDevEtl.Create(_connectionFactory, cacheFilePath);
var result = await pipeline.ExecuteAsync();
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.JdeUser");
count.ShouldBe((int)result.TotalRows);
}
[Fact]
public async Task Registry_RunAsync_LoadsTable()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var result = await registry.RunAsync("JdeUser");
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
var cacheFilePath = Path.Combine(_cacheDirectory, JdeUserDevEtl.CacheFileName);
Should.Throw<ArgumentNullException>(() => JdeUserDevEtl.Create(null!, cacheFilePath));
}
}
@@ -1,166 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for OrgHierarchy development ETL.
/// Requires: Local SQL Server, CACHED_DB_FILES directory with orghierarchy.json.zstd
/// </summary>
public class OrgHierarchyDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public OrgHierarchyDevEtlTests()
{
// Load configuration
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
// Ensure OrgHierarchy table is empty before test
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.OrgHierarchy");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
// Arrange
var cacheFilePath = Path.Combine(_cacheDirectory, OrgHierarchyDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath))
{
// Skip test if cache file doesn't exist
return;
}
// Act
var pipeline = OrgHierarchyDevEtl.Create(_connectionFactory, cacheFilePath);
// Assert
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("OrgHierarchy_Dev");
}
[Fact]
public async Task Execute_LoadsOrgHierarchyData()
{
// Arrange
var cacheFilePath = Path.Combine(_cacheDirectory, OrgHierarchyDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath))
{
// Skip test if cache file doesn't exist
return;
}
var pipeline = OrgHierarchyDevEtl.Create(_connectionFactory, cacheFilePath);
// Act
var result = await pipeline.ExecuteAsync();
// Assert
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0, "Should load at least one row");
// Verify data in database
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.OrgHierarchy");
count.ShouldBe((int)result.TotalRows, "Database row count should match pipeline result");
}
[Fact]
public async Task Registry_RunAsync_LoadsOrgHierarchy()
{
// Arrange
if (!Directory.Exists(_cacheDirectory))
{
// Skip test if cache directory doesn't exist
return;
}
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
// Act
var result = await registry.RunAsync("OrgHierarchy");
// Assert
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
// Arrange
var cacheFilePath = Path.Combine(_cacheDirectory, OrgHierarchyDevEtl.CacheFileName);
// Act & Assert
Should.Throw<ArgumentNullException>(() => OrgHierarchyDevEtl.Create(null!, cacheFilePath));
}
[Fact]
public void Create_WithEmptyCacheFilePath_ThrowsArgumentException()
{
// Arrange
var mockFactory = Substitute.For<IDbConnectionFactory>();
// Act & Assert
Should.Throw<ArgumentException>(() => OrgHierarchyDevEtl.Create(mockFactory, string.Empty));
}
[Fact]
public void Create_WithNonExistentCacheFile_ThrowsFileNotFoundException()
{
// Arrange
var mockFactory = Substitute.For<IDbConnectionFactory>();
var nonExistentPath = "/nonexistent/path/orghierarchy.json.zstd";
// Act & Assert
Should.Throw<FileNotFoundException>(() => OrgHierarchyDevEtl.Create(mockFactory, nonExistentPath));
}
[Fact]
public void DevEtlRegistry_GetAvailableTables_IncludesOrgHierarchy()
{
// Arrange
if (!Directory.Exists(_cacheDirectory))
{
return;
}
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
// Act
var tables = registry.GetAvailableTables().ToList();
// Assert
tables.ShouldContain("OrgHierarchy");
}
}
@@ -1,117 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for ProfitCenter development ETL.
/// Requires: Local SQL Server, CACHED_DB_FILES directory with profitcenter.json.zstd
/// </summary>
public class ProfitCenterDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public ProfitCenterDevEtlTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.ProfitCenter");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
var cacheFilePath = Path.Combine(_cacheDirectory, ProfitCenterDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = ProfitCenterDevEtl.Create(_connectionFactory, cacheFilePath);
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("ProfitCenter_Dev");
}
[Fact]
public async Task Execute_LoadsProfitCenterData()
{
var cacheFilePath = Path.Combine(_cacheDirectory, ProfitCenterDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = ProfitCenterDevEtl.Create(_connectionFactory, cacheFilePath);
var result = await pipeline.ExecuteAsync();
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0, "Should load at least one row");
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.ProfitCenter");
count.ShouldBe((int)result.TotalRows);
}
[Fact]
public async Task Registry_RunAsync_LoadsProfitCenter()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var result = await registry.RunAsync("ProfitCenter");
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
var cacheFilePath = Path.Combine(_cacheDirectory, ProfitCenterDevEtl.CacheFileName);
Should.Throw<ArgumentNullException>(() => ProfitCenterDevEtl.Create(null!, cacheFilePath));
}
[Fact]
public void Create_WithEmptyCacheFilePath_ThrowsArgumentException()
{
var mockFactory = Substitute.For<IDbConnectionFactory>();
Should.Throw<ArgumentException>(() => ProfitCenterDevEtl.Create(mockFactory, string.Empty));
}
[Fact]
public void DevEtlRegistry_GetAvailableTables_IncludesProfitCenter()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var tables = registry.GetAvailableTables().ToList();
tables.ShouldContain("ProfitCenter");
}
}
@@ -1,96 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for RouteMaster development ETL.
/// </summary>
public class RouteMasterDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public RouteMasterDevEtlTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.RouteMaster");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
var cacheFilePath = Path.Combine(_cacheDirectory, RouteMasterDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = RouteMasterDevEtl.Create(_connectionFactory, cacheFilePath);
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("RouteMaster_Dev");
}
[Fact]
public async Task Execute_LoadsData()
{
var cacheFilePath = Path.Combine(_cacheDirectory, RouteMasterDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = RouteMasterDevEtl.Create(_connectionFactory, cacheFilePath);
var result = await pipeline.ExecuteAsync();
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.RouteMaster");
count.ShouldBe((int)result.TotalRows);
}
[Fact]
public async Task Registry_RunAsync_LoadsTable()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var result = await registry.RunAsync("RouteMaster");
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
var cacheFilePath = Path.Combine(_cacheDirectory, RouteMasterDevEtl.CacheFileName);
Should.Throw<ArgumentNullException>(() => RouteMasterDevEtl.Create(null!, cacheFilePath));
}
}
@@ -1,117 +0,0 @@
using Dapper;
using JdeScoping.DataAccess;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Dev;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Dev.Tests;
/// <summary>
/// Integration tests for WorkCenter development ETL.
/// Requires: Local SQL Server, CACHED_DB_FILES directory with workcenter.json.zstd
/// </summary>
public class WorkCenterDevEtlTests : IAsyncLifetime
{
private readonly string _connectionString;
private readonly string _cacheDirectory;
private readonly IDbConnectionFactory _connectionFactory;
public WorkCenterDevEtlTests()
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
_connectionString = config.GetConnectionString("LotFinderDB")
?? throw new InvalidOperationException("LotFinderDB connection string not configured.");
_cacheDirectory = config["DevEtl:CacheDirectory"]
?? Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "CACHED_DB_FILES");
_connectionFactory = new DbConnectionFactory(config, NullLogger<DbConnectionFactory>.Instance);
}
public async Task InitializeAsync()
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await connection.ExecuteAsync("TRUNCATE TABLE dbo.WorkCenter");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public void Create_ReturnsValidPipeline()
{
var cacheFilePath = Path.Combine(_cacheDirectory, WorkCenterDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = WorkCenterDevEtl.Create(_connectionFactory, cacheFilePath);
pipeline.ShouldNotBeNull();
pipeline.PipelineName.ShouldBe("WorkCenter_Dev");
}
[Fact]
public async Task Execute_LoadsWorkCenterData()
{
var cacheFilePath = Path.Combine(_cacheDirectory, WorkCenterDevEtl.CacheFileName);
if (!File.Exists(cacheFilePath)) return;
var pipeline = WorkCenterDevEtl.Create(_connectionFactory, cacheFilePath);
var result = await pipeline.ExecuteAsync();
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0, "Should load at least one row");
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
var count = await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.WorkCenter");
count.ShouldBe((int)result.TotalRows);
}
[Fact]
public async Task Registry_RunAsync_LoadsWorkCenter()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var result = await registry.RunAsync("WorkCenter");
result.Success.ShouldBeTrue(result.Error?.Message ?? "Pipeline should succeed");
result.TotalRows.ShouldBeGreaterThan(0);
}
[Fact]
public void Create_WithNullConnectionFactory_ThrowsArgumentNullException()
{
var cacheFilePath = Path.Combine(_cacheDirectory, WorkCenterDevEtl.CacheFileName);
Should.Throw<ArgumentNullException>(() => WorkCenterDevEtl.Create(null!, cacheFilePath));
}
[Fact]
public void Create_WithEmptyCacheFilePath_ThrowsArgumentException()
{
var mockFactory = Substitute.For<IDbConnectionFactory>();
Should.Throw<ArgumentException>(() => WorkCenterDevEtl.Create(mockFactory, string.Empty));
}
[Fact]
public void DevEtlRegistry_GetAvailableTables_IncludesWorkCenter()
{
if (!Directory.Exists(_cacheDirectory)) return;
var registry = new DevEtlRegistry(_connectionFactory, _cacheDirectory);
var tables = registry.GetAvailableTables().ToList();
tables.ShouldContain("WorkCenter");
}
}
@@ -66,7 +66,7 @@ public class SearchRepositoryTests
var repository = new SearchRepository(_connectionFactory, _logger);
// Assert
repository.ShouldBeAssignableTo<JdeScoping.DataSync.Contracts.ISearchRepository>();
repository.ShouldBeAssignableTo<Contracts.ISearchRepository>();
}
[Fact]