fix(data-access): correct self-referential SQL in WorkCenter filter
The WHERE clause was comparing Code to itself instead of the aliased table reference, which would always be true.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using JdeScoping.Api.Extensions;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Models;
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
@@ -16,7 +17,7 @@ namespace JdeScoping.Api.Controllers;
|
||||
/// <summary>
|
||||
/// Authentication endpoints for Blazor WASM client
|
||||
/// </summary>
|
||||
[Route("api/auth")]
|
||||
[Route(ApiRoutes.Auth.Base)]
|
||||
[ApiController]
|
||||
public class AuthController : ApiControllerBase
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,7 +11,7 @@ namespace JdeScoping.Api.Controllers;
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/fileio")]
|
||||
[Route(ApiRoutes.FileIO.Base)]
|
||||
public partial class FileIOController : ApiControllerBase
|
||||
{
|
||||
private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -8,7 +9,7 @@ namespace JdeScoping.Api.Controllers;
|
||||
/// <summary>
|
||||
/// Lookup/autocomplete endpoints (no authorization required)
|
||||
/// </summary>
|
||||
[Route("api/lookup")]
|
||||
[Route(ApiRoutes.Lookup.Base)]
|
||||
[ApiController]
|
||||
public class LookupController : ApiControllerBase
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using JdeScoping.Api.Hubs;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.Models;
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
@@ -15,7 +16,7 @@ namespace JdeScoping.Api.Controllers;
|
||||
/// <summary>
|
||||
/// Search management controller
|
||||
/// </summary>
|
||||
[Route("api/search")]
|
||||
[Route(ApiRoutes.Search.Base)]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class SearchController : ApiControllerBase
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.Models;
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation of IAuthApiClient.
|
||||
/// </summary>
|
||||
public class AuthApiClient : ApiClientBase, IAuthApiClient
|
||||
{
|
||||
public AuthApiClient(HttpClient httpClient) : base(httpClient) { }
|
||||
|
||||
public Task<ApiResult<PublicKeyResponse>> GetPublicKeyAsync(CancellationToken ct = default)
|
||||
=> GetAsync<PublicKeyResponse>(ApiRoutes.Auth.PublicKey, ct);
|
||||
|
||||
public Task<ApiResult<LoginResultModel>> LoginAsync(EncryptedLoginRequest request, CancellationToken ct = default)
|
||||
=> PostAsync<LoginResultModel, EncryptedLoginRequest>(ApiRoutes.Auth.Login, request, ct);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation of IFileApiClient.
|
||||
/// </summary>
|
||||
public class FileApiClient : ApiClientBase, IFileApiClient
|
||||
{
|
||||
public FileApiClient(HttpClient httpClient) : base(httpClient) { }
|
||||
|
||||
// Downloads
|
||||
|
||||
public Task<ApiResult<byte[]>> DownloadWorkOrdersTemplateAsync(IReadOnlyList<WorkOrderViewModel>? existingData = null, CancellationToken ct = default)
|
||||
=> PostForBytesAsync(ApiRoutes.FileIO.DownloadWorkOrders, existingData, ct);
|
||||
|
||||
public Task<ApiResult<byte[]>> DownloadItemsTemplateAsync(IReadOnlyList<ItemViewModel>? existingData = null, CancellationToken ct = default)
|
||||
=> PostForBytesAsync(ApiRoutes.FileIO.DownloadItems, existingData, ct);
|
||||
|
||||
public Task<ApiResult<byte[]>> DownloadComponentLotsTemplateAsync(IReadOnlyList<LotViewModel>? existingData = null, CancellationToken ct = default)
|
||||
=> PostForBytesAsync(ApiRoutes.FileIO.DownloadComponentLots, existingData, ct);
|
||||
|
||||
public Task<ApiResult<byte[]>> DownloadPartOperationsTemplateAsync(IReadOnlyList<PartOperationViewModel>? existingData = null, CancellationToken ct = default)
|
||||
=> PostForBytesAsync(ApiRoutes.FileIO.DownloadPartOperations, existingData, ct);
|
||||
|
||||
// Uploads
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<WorkOrderViewModel>>> UploadWorkOrdersAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<WorkOrderViewModel>>(ApiRoutes.FileIO.UploadWorkOrders, fileStream, fileName, ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<ItemViewModel>>> UploadItemsAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<ItemViewModel>>(ApiRoutes.FileIO.UploadItems, fileStream, fileName, ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<LotViewModel>>> UploadComponentLotsAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<LotViewModel>>(ApiRoutes.FileIO.UploadComponentLots, fileStream, fileName, ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<PartOperationViewModel>>> UploadPartOperationsAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<PartOperationViewModel>>(ApiRoutes.FileIO.UploadPartOperations, fileStream, fileName, ct);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation of ILookupApiClient.
|
||||
/// </summary>
|
||||
public class LookupApiClient : ApiClientBase, ILookupApiClient
|
||||
{
|
||||
public LookupApiClient(HttpClient httpClient) : base(httpClient) { }
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<ItemViewModel>>> FindItemsAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<ItemViewModel>>(ApiRoutes.Lookup.FindItems(query), ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<ProfitCenterViewModel>>> FindProfitCentersAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<ProfitCenterViewModel>>(ApiRoutes.Lookup.FindProfitCenters(query), ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<WorkCenterViewModel>>> FindWorkCentersAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<WorkCenterViewModel>>(ApiRoutes.Lookup.FindWorkCenters(query), ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<JdeUserViewModel>>> FindOperatorsAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<JdeUserViewModel>>(ApiRoutes.Lookup.FindOperators(query), ct);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation of ISearchApiClient.
|
||||
/// </summary>
|
||||
public class SearchApiClient : ApiClientBase, ISearchApiClient
|
||||
{
|
||||
public SearchApiClient(HttpClient httpClient) : base(httpClient) { }
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetUserSearchesAsync(CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<SearchViewModel>>(ApiRoutes.Search.Base, ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetQueuedSearchesAsync(CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<SearchViewModel>>(ApiRoutes.Search.Queue, ct);
|
||||
|
||||
public Task<ApiResult<SearchViewModel>> GetSearchAsync(int id, CancellationToken ct = default)
|
||||
=> GetAsync<SearchViewModel>(ApiRoutes.Search.GetById(id), ct);
|
||||
|
||||
public Task<ApiResult<SearchViewModel>> CopySearchAsync(int id, CancellationToken ct = default)
|
||||
=> GetAsync<SearchViewModel>(ApiRoutes.Search.GetCopy(id), ct);
|
||||
|
||||
public Task<ApiResult<int>> CreateSearchAsync(SearchViewModel search, CancellationToken ct = default)
|
||||
=> PostAsync<int, SearchViewModel>(ApiRoutes.Search.Base, search, ct);
|
||||
|
||||
public Task<ApiResult<byte[]>> GetResultsAsync(int id, CancellationToken ct = default)
|
||||
=> GetBytesAsync(ApiRoutes.Search.GetResults(id), ct);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Shared API route constants. Use in controller attributes and client implementations.
|
||||
/// </summary>
|
||||
public static class ApiRoutes
|
||||
{
|
||||
/// <summary>
|
||||
/// Routes for search API endpoints.
|
||||
/// </summary>
|
||||
public static class Search
|
||||
{
|
||||
/// <summary>Base route for search endpoints.</summary>
|
||||
public const string Base = "api/search";
|
||||
|
||||
/// <summary>Route for queued searches.</summary>
|
||||
public const string Queue = "api/search/queue";
|
||||
|
||||
/// <summary>Route template for getting a search by ID (use in controller attributes).</summary>
|
||||
public const string ById = "{id:int}";
|
||||
|
||||
/// <summary>Route template for copying a search (use in controller attributes).</summary>
|
||||
public const string Copy = "{id:int}/copy";
|
||||
|
||||
/// <summary>Route template for getting search results (use in controller attributes).</summary>
|
||||
public const string Results = "{id:int}/results";
|
||||
|
||||
/// <summary>Builds the route to get a specific search.</summary>
|
||||
public static string GetById(int id) => $"api/search/{id}";
|
||||
|
||||
/// <summary>Builds the route to copy a search.</summary>
|
||||
public static string GetCopy(int id) => $"api/search/{id}/copy";
|
||||
|
||||
/// <summary>Builds the route to get search results.</summary>
|
||||
public static string GetResults(int id) => $"api/search/{id}/results";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes for lookup/autocomplete API endpoints.
|
||||
/// </summary>
|
||||
public static class Lookup
|
||||
{
|
||||
/// <summary>Base route for lookup endpoints.</summary>
|
||||
public const string Base = "api/lookup";
|
||||
|
||||
/// <summary>Route for item lookup.</summary>
|
||||
public const string Items = "api/lookup/items";
|
||||
|
||||
/// <summary>Route for profit center lookup.</summary>
|
||||
public const string ProfitCenters = "api/lookup/profit-centers";
|
||||
|
||||
/// <summary>Route for work center lookup.</summary>
|
||||
public const string WorkCenters = "api/lookup/work-centers";
|
||||
|
||||
/// <summary>Route for operator lookup.</summary>
|
||||
public const string Operators = "api/lookup/operators";
|
||||
|
||||
/// <summary>Builds the route to find items with URL-encoded query.</summary>
|
||||
public static string FindItems(string query) => $"{Items}?q={Uri.EscapeDataString(query)}";
|
||||
|
||||
/// <summary>Builds the route to find profit centers with URL-encoded query.</summary>
|
||||
public static string FindProfitCenters(string query) => $"{ProfitCenters}?q={Uri.EscapeDataString(query)}";
|
||||
|
||||
/// <summary>Builds the route to find work centers with URL-encoded query.</summary>
|
||||
public static string FindWorkCenters(string query) => $"{WorkCenters}?q={Uri.EscapeDataString(query)}";
|
||||
|
||||
/// <summary>Builds the route to find operators with URL-encoded query.</summary>
|
||||
public static string FindOperators(string query) => $"{Operators}?q={Uri.EscapeDataString(query)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes for authentication API endpoints.
|
||||
/// </summary>
|
||||
public static class Auth
|
||||
{
|
||||
/// <summary>Base route for auth endpoints.</summary>
|
||||
public const string Base = "api/auth";
|
||||
|
||||
/// <summary>Route to get the public key for credential encryption.</summary>
|
||||
public const string PublicKey = "api/auth/public-key";
|
||||
|
||||
/// <summary>Route for login.</summary>
|
||||
public const string Login = "api/auth/login";
|
||||
|
||||
/// <summary>Route for logout.</summary>
|
||||
public const string Logout = "api/auth/logout";
|
||||
|
||||
/// <summary>Route to get current user info.</summary>
|
||||
public const string Me = "api/auth/me";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes for file upload/download API endpoints.
|
||||
/// </summary>
|
||||
public static class FileIO
|
||||
{
|
||||
/// <summary>Base route for file IO endpoints.</summary>
|
||||
public const string Base = "api/fileio";
|
||||
|
||||
/// <summary>Route to download work orders template.</summary>
|
||||
public const string DownloadWorkOrders = "api/fileio/workorders/download";
|
||||
|
||||
/// <summary>Route to download items template.</summary>
|
||||
public const string DownloadItems = "api/fileio/items/download";
|
||||
|
||||
/// <summary>Route to download component lots template.</summary>
|
||||
public const string DownloadComponentLots = "api/fileio/componentlots/download";
|
||||
|
||||
/// <summary>Route to download part operations template.</summary>
|
||||
public const string DownloadPartOperations = "api/fileio/partoperations/download";
|
||||
|
||||
/// <summary>Route to upload work orders.</summary>
|
||||
public const string UploadWorkOrders = "api/fileio/workorders/upload";
|
||||
|
||||
/// <summary>Route to upload items.</summary>
|
||||
public const string UploadItems = "api/fileio/items/upload";
|
||||
|
||||
/// <summary>Route to upload component lots.</summary>
|
||||
public const string UploadComponentLots = "api/fileio/componentlots/upload";
|
||||
|
||||
/// <summary>Route to upload part operations.</summary>
|
||||
public const string UploadPartOperations = "api/fileio/partoperations/upload";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.Models;
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for authentication API operations.
|
||||
/// </summary>
|
||||
public interface IAuthApiClient
|
||||
{
|
||||
/// <summary>Gets the server's RSA public key for encrypting login credentials.</summary>
|
||||
Task<ApiResult<PublicKeyResponse>> GetPublicKeyAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Authenticates with encrypted credentials.</summary>
|
||||
Task<ApiResult<LoginResultModel>> LoginAsync(EncryptedLoginRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Logs out the current user.</summary>
|
||||
Task<ApiResult<Unit>> LogoutAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Gets the current authenticated user's information.</summary>
|
||||
Task<ApiResult<UserInfo>> GetCurrentUserAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for file upload/download API operations.
|
||||
/// Note: Uses Stream for client-side; controllers use IFormFile.
|
||||
/// </summary>
|
||||
public interface IFileApiClient
|
||||
{
|
||||
// Downloads (POST with existing data, returns Excel bytes)
|
||||
|
||||
/// <summary>Downloads work orders template, optionally pre-filled with existing data.</summary>
|
||||
Task<ApiResult<byte[]>> DownloadWorkOrdersTemplateAsync(IReadOnlyList<WorkOrderViewModel>? existingData = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Downloads items template, optionally pre-filled with existing data.</summary>
|
||||
Task<ApiResult<byte[]>> DownloadItemsTemplateAsync(IReadOnlyList<ItemViewModel>? existingData = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Downloads component lots template, optionally pre-filled with existing data.</summary>
|
||||
Task<ApiResult<byte[]>> DownloadComponentLotsTemplateAsync(IReadOnlyList<LotViewModel>? existingData = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Downloads part operations template, optionally pre-filled with existing data.</summary>
|
||||
Task<ApiResult<byte[]>> DownloadPartOperationsTemplateAsync(IReadOnlyList<PartOperationViewModel>? existingData = null, CancellationToken ct = default);
|
||||
|
||||
// Uploads (multipart form, returns parsed data)
|
||||
|
||||
/// <summary>Uploads work orders Excel file and returns parsed data.</summary>
|
||||
Task<ApiResult<IReadOnlyList<WorkOrderViewModel>>> UploadWorkOrdersAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Uploads items Excel file and returns parsed data.</summary>
|
||||
Task<ApiResult<IReadOnlyList<ItemViewModel>>> UploadItemsAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Uploads component lots Excel file and returns parsed data.</summary>
|
||||
Task<ApiResult<IReadOnlyList<LotViewModel>>> UploadComponentLotsAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Uploads part operations Excel file and returns parsed data.</summary>
|
||||
Task<ApiResult<IReadOnlyList<PartOperationViewModel>>> UploadPartOperationsAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for lookup/autocomplete API operations.
|
||||
/// </summary>
|
||||
public interface ILookupApiClient
|
||||
{
|
||||
/// <summary>Finds items matching the search query.</summary>
|
||||
Task<ApiResult<IReadOnlyList<ItemViewModel>>> FindItemsAsync(string query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Finds profit centers matching the search query.</summary>
|
||||
Task<ApiResult<IReadOnlyList<ProfitCenterViewModel>>> FindProfitCentersAsync(string query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Finds work centers matching the search query.</summary>
|
||||
Task<ApiResult<IReadOnlyList<WorkCenterViewModel>>> FindWorkCentersAsync(string query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Finds operators (JDE users) matching the search query.</summary>
|
||||
Task<ApiResult<IReadOnlyList<JdeUserViewModel>>> FindOperatorsAsync(string query, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for search API operations.
|
||||
/// </summary>
|
||||
public interface ISearchApiClient
|
||||
{
|
||||
/// <summary>Gets all searches for the current user.</summary>
|
||||
Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetUserSearchesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Gets all queued searches.</summary>
|
||||
Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetQueuedSearchesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Gets a specific search by ID.</summary>
|
||||
Task<ApiResult<SearchViewModel>> GetSearchAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Copies an existing search to create a new one (returns copy without persisting).</summary>
|
||||
Task<ApiResult<SearchViewModel>> CopySearchAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Creates and submits a new search.</summary>
|
||||
Task<ApiResult<int>> CreateSearchAsync(SearchViewModel search, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Downloads the results for a completed search as Excel bytes.</summary>
|
||||
Task<ApiResult<byte[]>> GetResultsAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// General API error with message and optional status code.
|
||||
/// </summary>
|
||||
/// <param name="Message">Error message describing what went wrong.</param>
|
||||
/// <param name="StatusCode">Optional HTTP status code.</param>
|
||||
public readonly record struct ApiError(string Message, int? StatusCode = null);
|
||||
@@ -0,0 +1,39 @@
|
||||
using OneOf;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Standard API result type for client-side operations.
|
||||
/// Represents either success with value T, or one of several error types.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The success value type.</typeparam>
|
||||
[GenerateOneOf]
|
||||
public partial class ApiResult<T> : OneOfBase<T, NotFound, ValidationError, Unauthorized, Forbidden, ApiError>
|
||||
{
|
||||
/// <summary>Returns true if the result is a success value.</summary>
|
||||
public bool IsSuccess => IsT0;
|
||||
|
||||
/// <summary>Returns true if the result is NotFound.</summary>
|
||||
public bool IsNotFound => IsT1;
|
||||
|
||||
/// <summary>Returns true if the result is a ValidationError.</summary>
|
||||
public bool IsValidationError => IsT2;
|
||||
|
||||
/// <summary>Returns true if the result is Unauthorized.</summary>
|
||||
public bool IsUnauthorized => IsT3;
|
||||
|
||||
/// <summary>Returns true if the result is Forbidden.</summary>
|
||||
public bool IsForbidden => IsT4;
|
||||
|
||||
/// <summary>Returns true if the result is an ApiError.</summary>
|
||||
public bool IsError => IsT5;
|
||||
|
||||
/// <summary>Gets the success value. Throws if not a success.</summary>
|
||||
public new T Value => AsT0;
|
||||
|
||||
/// <summary>Gets the validation error. Throws if not a validation error.</summary>
|
||||
public ValidationError ValidationError => AsT2;
|
||||
|
||||
/// <summary>Gets the API error. Throws if not an API error.</summary>
|
||||
public ApiError Error => AsT5;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Access denied (HTTP 403).
|
||||
/// </summary>
|
||||
public readonly record struct Forbidden;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Resource not found (HTTP 404).
|
||||
/// </summary>
|
||||
public readonly record struct NotFound;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication required (HTTP 401).
|
||||
/// </summary>
|
||||
public readonly record struct Unauthorized;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Empty success type for void operations.
|
||||
/// </summary>
|
||||
public readonly record struct Unit;
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Validation failed (HTTP 400) with field-level errors.
|
||||
/// Maps to ASP.NET Core ValidationProblemDetails format.
|
||||
/// </summary>
|
||||
/// <param name="FieldErrors">Dictionary mapping field names to error messages.</param>
|
||||
public readonly record struct ValidationError(IReadOnlyDictionary<string, string[]> FieldErrors)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a ValidationError from a dictionary of field errors.
|
||||
/// </summary>
|
||||
public static ValidationError FromDictionary(Dictionary<string, string[]> errors)
|
||||
=> new(errors);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||
<PackageReference Include="OneOf" Version="3.0.271" />
|
||||
<PackageReference Include="OneOf.SourceGenerator" Version="3.0.271" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
|
||||
@@ -182,9 +182,9 @@ public sealed class SqlKataSearchQueryBuilder : ISearchQueryBuilder
|
||||
|
||||
--Insert from work centers directly
|
||||
INSERT INTO #P_WorkCenters (Code)
|
||||
SELECT Code
|
||||
FROM dbo.fn_GetSearchWorkCenters(@SearchId)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM #P_WorkCenters WHERE Code = Code);
|
||||
SELECT wc.Code
|
||||
FROM dbo.fn_GetSearchWorkCenters(@SearchId) wc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM #P_WorkCenters pwc WHERE pwc.Code = wc.Code);
|
||||
""";
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,12 @@ namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
/// </summary>
|
||||
public class DbBulkImportDestination : IImportDestination
|
||||
{
|
||||
private const int DefaultBatchSize = 10000;
|
||||
private const int DefaultBatchSize = 100000;
|
||||
private const int DefaultCommandTimeoutSeconds = 600;
|
||||
|
||||
/// <summary>Use this for very large tables to avoid timeout during bulk copy.</summary>
|
||||
public const int InfiniteTimeout = 0;
|
||||
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly string _tableName;
|
||||
private readonly int _batchSize;
|
||||
@@ -73,8 +76,8 @@ public class DbBulkImportDestination : IImportDestination
|
||||
// Get destination columns for column mapping
|
||||
var destColumns = await GetDestinationColumnsAsync(connection, cancellationToken);
|
||||
|
||||
// Bulk copy data
|
||||
using var bulkCopy = new SqlBulkCopy(connection)
|
||||
// Bulk copy data with TableLock for reduced logging overhead
|
||||
using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.TableLock, null)
|
||||
{
|
||||
DestinationTableName = qualifiedName,
|
||||
BatchSize = _batchSize,
|
||||
@@ -98,8 +101,8 @@ public class DbBulkImportDestination : IImportDestination
|
||||
$"No columns from source exist in destination table '{_tableName}'. " +
|
||||
"Check column names match between source query and destination table.");
|
||||
|
||||
// Track rows via event
|
||||
bulkCopy.NotifyAfter = _batchSize;
|
||||
// Track rows via event (notify less frequently to reduce overhead)
|
||||
bulkCopy.NotifyAfter = _batchSize * 10;
|
||||
bulkCopy.SqlRowsCopied += (_, e) =>
|
||||
{
|
||||
totalRows = e.RowsCopied;
|
||||
|
||||
Reference in New Issue
Block a user