Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,54 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
namespace JdeScoping.Infrastructure.Auth;
/// <summary>
/// Fake authentication service for development mode.
/// Accepts any credentials and returns a predefined user.
/// </summary>
public sealed class FakeAuthService : IAuthService
{
/// <inheritdoc />
public Task<AuthResult> AuthenticateAsync(
string username,
string password,
CancellationToken ct = default)
{
// Accept any credentials in development mode
var user = CreateFakeUser(username);
return Task.FromResult(new AuthResult(true, user, null));
}
/// <inheritdoc />
public Task<UserInfo?> GetUserInfoAsync(
string username,
CancellationToken ct = default)
{
var user = CreateFakeUser(username);
return Task.FromResult<UserInfo?>(user);
}
/// <inheritdoc />
public Task<bool> IsInGroupAsync(
string username,
string groupName,
CancellationToken ct = default)
{
// Always return true in development mode
return Task.FromResult(true);
}
private static UserInfo CreateFakeUser(string username)
{
return new UserInfo
{
Dn = $"CN={username},OU=Users,DC=example,DC=com",
Username = username.ToLowerInvariant(),
FirstName = "Dev",
LastName = "User",
EmailAddress = $"{username}@example.com",
Title = "Developer"
};
}
}
@@ -0,0 +1,242 @@
using System.DirectoryServices.Protocols;
using System.Net;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.Auth;
/// <summary>
/// LDAP-based authentication service implementation using System.DirectoryServices.Protocols
/// </summary>
public sealed class LdapAuthService : IAuthService
{
private const string LdapLookupFormat = "(sAMAccountName={0})";
private readonly LdapOptions _options;
private readonly AuthOptions _authOptions;
private readonly ILogger<LdapAuthService> _logger;
public LdapAuthService(
IOptions<LdapOptions> options,
IOptions<AuthOptions> authOptions,
ILogger<LdapAuthService> logger)
{
_options = options.Value;
_authOptions = authOptions.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task<AuthResult> AuthenticateAsync(
string username,
string password,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new AuthResult(false, null, "Username and password are required");
}
// Check if user is in admin bypass list
var isAdminBypass = _authOptions.AdminBypassUsers
.Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase));
// Try each configured LDAP server
string? lastError = null;
foreach (var serverUrl in _options.ServerUrls)
{
try
{
ct.ThrowIfCancellationRequested();
// Attempt authentication
if (!await TryBindAsync(serverUrl, username, password, ct))
{
return new AuthResult(false, null, "Incorrect username or password");
}
// Verify group membership (unless admin bypass)
if (!isAdminBypass && !string.IsNullOrEmpty(_options.GroupDn))
{
if (!await IsInGroupInternalAsync(serverUrl, username, password, _options.GroupDn, ct))
{
return new AuthResult(false, null, "User is not a member of the required security group");
}
}
// Lookup user info
var userInfo = await LookupUserAsync(serverUrl, username, password, ct);
if (userInfo is null)
{
return new AuthResult(false, null, "Failed to retrieve user information");
}
_logger.LogInformation("User {Username} authenticated successfully via {Server}", username, serverUrl);
return new AuthResult(true, userInfo, null);
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl);
lastError = ex.Message;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during LDAP authentication for server {Server}", serverUrl);
lastError = ex.Message;
}
}
return new AuthResult(false, null, lastError ?? "Unable to connect to directory server");
}
/// <inheritdoc />
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
{
// Not implemented for LDAP - user info is only available during authentication
throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
}
/// <inheritdoc />
public async Task<bool> IsInGroupAsync(
string username,
string groupName,
CancellationToken ct = default)
{
// This method requires stored credentials or service account - not supported
// Group membership is checked during authentication when credentials are available
throw new NotSupportedException("IsInGroupAsync requires password for LDAP lookup. Use AuthenticateAsync instead.");
}
private async Task<bool> TryBindAsync(
string serverUrl,
string username,
string password,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
var credential = new NetworkCredential(username, password);
connection.Credential = credential;
connection.AuthType = AuthType.Negotiate;
try
{
await Task.Run(() => connection.Bind(), ct);
return true;
}
catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
{
return false;
}
}
private async Task<bool> IsInGroupInternalAsync(
string serverUrl,
string username,
string password,
string groupDn,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
connection.Credential = new NetworkCredential(username, password);
connection.AuthType = AuthType.Negotiate;
await Task.Run(() => connection.Bind(), ct);
var searchRequest = new SearchRequest(
_options.SearchBase,
string.Format(LdapLookupFormat, EscapeLdapSearchFilter(username)),
SearchScope.Subtree,
"memberOf");
var response = (SearchResponse)await Task.Run(
() => connection.SendRequest(searchRequest), ct);
foreach (SearchResultEntry entry in response.Entries)
{
var memberOf = entry.Attributes["memberOf"];
if (memberOf != null)
{
foreach (var group in memberOf.GetValues(typeof(string)))
{
if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
return false;
}
private async Task<UserInfo?> LookupUserAsync(
string serverUrl,
string username,
string password,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
connection.Credential = new NetworkCredential(username, password);
connection.AuthType = AuthType.Negotiate;
await Task.Run(() => connection.Bind(), ct);
var searchRequest = new SearchRequest(
_options.SearchBase,
string.Format(LdapLookupFormat, EscapeLdapSearchFilter(username)),
SearchScope.Subtree,
"distinguishedName", "givenName", "sn", "mail", "title");
var response = (SearchResponse)await Task.Run(
() => connection.SendRequest(searchRequest), ct);
if (response.Entries.Count == 0) return null;
var entry = response.Entries[0];
return new UserInfo
{
Dn = GetAttribute(entry, "distinguishedName"),
Username = username.ToLowerInvariant(),
FirstName = GetAttribute(entry, "givenName"),
LastName = GetAttribute(entry, "sn"),
EmailAddress = GetAttribute(entry, "mail"),
Title = GetAttribute(entry, "title")
};
}
private LdapConnection CreateConnection(string serverUrl)
{
var connection = new LdapConnection(serverUrl);
connection.SessionOptions.ProtocolVersion = 3;
connection.SessionOptions.SecureSocketLayer = false;
connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds);
return connection;
}
private static string GetAttribute(SearchResultEntry entry, string name)
{
var attr = entry.Attributes[name];
return attr?.Count > 0 ? (string)attr[0] : string.Empty;
}
/// <summary>
/// Escapes special characters in LDAP search filter values
/// </summary>
private static string EscapeLdapSearchFilter(string value)
{
if (string.IsNullOrEmpty(value))
return value;
return value
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
}
@@ -0,0 +1,65 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Options;
using JdeScoping.Infrastructure.Auth;
using JdeScoping.Infrastructure.Sources.Cms;
using JdeScoping.Infrastructure.Sources.Jde;
using Microsoft.Extensions.Configuration;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for registering infrastructure services.
/// </summary>
public static class InfrastructureDependencyInjection
{
/// <summary>
/// Adds infrastructure services (data sources, auth) to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration
services.Configure<DataSourceOptions>(
configuration.GetSection(DataSourceOptions.SectionName));
services.Configure<AuthOptions>(
configuration.GetSection(AuthOptions.SectionName));
services.Configure<LdapOptions>(
configuration.GetSection(LdapOptions.SectionName));
// Register data sources based on configuration
var dataSourceOptions = configuration
.GetSection(DataSourceOptions.SectionName)
.Get<DataSourceOptions>();
if (dataSourceOptions?.UseFileDataSource == true)
{
services.AddScoped<IJdeDataSource, JdeFileDataSource>();
services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
// Register auth service based on configuration
var authOptions = configuration
.GetSection(AuthOptions.SectionName)
.Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
services.AddScoped<IAuthService, LdapAuthService>();
}
return services;
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.0" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,43 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Quality;
using JdeScoping.Core.Options;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.Sources.Cms;
/// <summary>
/// File-based CMS data source for development/testing.
/// </summary>
public class CmsFileDataSource : ICmsDataSource
{
private readonly string _dataDirectory;
/// <summary>
/// Initializes a new instance of the <see cref="CmsFileDataSource"/> class.
/// </summary>
public CmsFileDataSource(IOptions<DataSourceOptions> options)
{
_dataDirectory = options.Value.FileDirectory;
}
/// <inheritdoc/>
public async IAsyncEnumerable<MisData> GetMisDataAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var filePath = Path.Combine(_dataDirectory, "misdata.json");
if (!File.Exists(filePath))
yield break;
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
var items = JsonSerializer.Deserialize<List<MisData>>(json) ?? [];
foreach (var item in items.Where(m => !minimumDt.HasValue || (m.ReleaseDate.HasValue && m.ReleaseDate.Value >= minimumDt)))
{
yield return item;
}
}
}
@@ -0,0 +1,46 @@
using System.Runtime.CompilerServices;
using Dapper;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Quality;
using Microsoft.Extensions.Configuration;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.Infrastructure.Sources.Cms;
/// <summary>
/// Oracle-based CMS data source for production use.
/// </summary>
public class CmsOracleDataSource : ICmsDataSource
{
private readonly string _connectionString;
/// <summary>
/// Initializes a new instance of the <see cref="CmsOracleDataSource"/> class.
/// </summary>
public CmsOracleDataSource(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("CMS")
?? throw new InvalidOperationException("CMS connection string not configured");
}
/// <inheritdoc/>
public async IAsyncEnumerable<MisData> GetMisDataAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
// TODO: Implement actual CMS query for MIS data
var sql = minimumDt.HasValue
? "SELECT * FROM MISDATA WHERE RELEASE_DATE >= :MinDate"
: "SELECT * FROM MISDATA";
var results = await connection.QueryAsync<MisData>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
}
@@ -0,0 +1,133 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.Core.Options;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.Sources.Jde;
/// <summary>
/// File-based JDE data source for development/testing.
/// </summary>
public class JdeFileDataSource : IJdeDataSource
{
private readonly string _dataDirectory;
/// <summary>
/// Initializes a new instance of the <see cref="JdeFileDataSource"/> class.
/// </summary>
public JdeFileDataSource(IOptions<DataSourceOptions> options)
{
_dataDirectory = options.Value.FileDirectory;
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<WorkOrder>("workorders.json", cancellationToken);
foreach (var item in items.Where(wo => !minimumDt.HasValue || (wo.LastUpdateDt.HasValue && wo.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<LotUsage> GetLotUsagesAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<LotUsage>("lotusages.json", cancellationToken);
foreach (var item in items.Where(lu => !minimumDt.HasValue || (lu.LastUpdateDt.HasValue && lu.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Lot> GetLotsAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<Lot>("lots.json", cancellationToken);
foreach (var item in items.Where(l => !minimumDt.HasValue || (l.LastUpdateDt.HasValue && l.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Item> GetItemsAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<Item>("items.json", cancellationToken);
foreach (var item in items.Where(i => !minimumDt.HasValue || (i.LastUpdateDt.HasValue && i.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkCenter> GetWorkCentersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<WorkCenter>("workcenters.json", cancellationToken);
foreach (var item in items.Where(wc => !minimumDt.HasValue || (wc.LastUpdateDt.HasValue && wc.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<ProfitCenter> GetProfitCentersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<ProfitCenter>("profitcenters.json", cancellationToken);
foreach (var item in items.Where(pc => !minimumDt.HasValue || (pc.LastUpdateDt.HasValue && pc.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<JdeUser> GetUsersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<JdeUser>("users.json", cancellationToken);
foreach (var item in items.Where(u => !minimumDt.HasValue || (u.LastUpdateDt.HasValue && u.LastUpdateDt.Value >= minimumDt)))
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Branch> GetBranchesAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var items = await LoadFromFileAsync<Branch>("branches.json", cancellationToken);
foreach (var item in items)
{
yield return item;
}
}
private async Task<List<T>> LoadFromFileAsync<T>(string fileName, CancellationToken cancellationToken)
{
var filePath = Path.Combine(_dataDirectory, fileName);
if (!File.Exists(filePath))
return [];
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
return JsonSerializer.Deserialize<List<T>>(json) ?? [];
}
}
@@ -0,0 +1,180 @@
using System.Runtime.CompilerServices;
using Dapper;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Inventory;
using JdeScoping.Core.Models.Organization;
using JdeScoping.Core.Models.WorkOrders;
using Microsoft.Extensions.Configuration;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.Infrastructure.Sources.Jde;
/// <summary>
/// Oracle-based JDE data source for production use.
/// </summary>
public class JdeOracleDataSource : IJdeDataSource
{
private readonly string _connectionString;
/// <summary>
/// Initializes a new instance of the <see cref="JdeOracleDataSource"/> class.
/// </summary>
public JdeOracleDataSource(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("JDE")
?? throw new InvalidOperationException("JDE connection string not configured");
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkOrder> GetWorkOrdersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
// TODO: Implement actual JDE query with proper column mapping
var sql = minimumDt.HasValue
? "SELECT * FROM F4801 WHERE UPMJ >= :MinDate"
: "SELECT * FROM F4801";
var results = await connection.QueryAsync<WorkOrder>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<LotUsage> GetLotUsagesAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
// TODO: Implement actual JDE query
var sql = minimumDt.HasValue
? "SELECT * FROM F4111 WHERE UPMJ >= :MinDate"
: "SELECT * FROM F4111";
var results = await connection.QueryAsync<LotUsage>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Lot> GetLotsAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var sql = minimumDt.HasValue
? "SELECT * FROM F4108 WHERE UPMJ >= :MinDate"
: "SELECT * FROM F4108";
var results = await connection.QueryAsync<Lot>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Item> GetItemsAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var sql = minimumDt.HasValue
? "SELECT * FROM F4101 WHERE UPMJ >= :MinDate"
: "SELECT * FROM F4101";
var results = await connection.QueryAsync<Item>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<WorkCenter> GetWorkCentersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var sql = minimumDt.HasValue
? "SELECT * FROM F30006 WHERE UPMJ >= :MinDate"
: "SELECT * FROM F30006";
var results = await connection.QueryAsync<WorkCenter>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<ProfitCenter> GetProfitCentersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
// TODO: Implement actual query for profit centers
var sql = "SELECT * FROM F0006";
var results = await connection.QueryAsync<ProfitCenter>(sql);
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<JdeUser> GetUsersAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var sql = minimumDt.HasValue
? "SELECT * FROM F0092 WHERE UPMJ >= :MinDate"
: "SELECT * FROM F0092";
var results = await connection.QueryAsync<JdeUser>(sql, new { MinDate = minimumDt });
foreach (var item in results)
{
yield return item;
}
}
/// <inheritdoc/>
public async IAsyncEnumerable<Branch> GetBranchesAsync(
DateTime? minimumDt = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await using var connection = new OracleConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
// TODO: Implement actual query for branches
var sql = "SELECT * FROM F0101";
var results = await connection.QueryAsync<Branch>(sql);
foreach (var item in results)
{
yield return item;
}
}
}