refactor: address code review findings across all projects
Apply comprehensive fixes from code reviews including: - Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase) - Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder) - Implement SecureStore for encrypted secrets storage - Fix error handling with proper HTTP status codes and logging - Optimize double enumeration in DevEtlRegistry - Add DataSync.Dev README for developer onboarding - Extract filter panel base classes to reduce duplication - Update code review docs to mark all issues as fixed
This commit is contained in:
@@ -191,3 +191,26 @@ The **[db_info.md](db_info.md)** file contains connection information for the lo
|
||||
- Connection strings for .NET configuration
|
||||
|
||||
**Note:** This file contains plain-text credentials for local development only. Do not commit to source control or use in production.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### DTOs and Models: Use Classes, Not Records
|
||||
|
||||
This project uses **classes** (not C# records) for all DTOs, ViewModels, and model types. This convention provides:
|
||||
|
||||
- **Consistency** - All model types follow the same pattern
|
||||
- **Explicit mutability** - Properties use `{ get; set; }` for clear intent
|
||||
- **Serialization compatibility** - Better compatibility with JSON serializers and Dapper
|
||||
- **Debugging ease** - Standard class behavior in debuggers and test assertions
|
||||
|
||||
```csharp
|
||||
// Preferred: class with explicit properties
|
||||
public class WorkOrderViewModel
|
||||
{
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
public DateTime? CompletionDate { get; set; }
|
||||
}
|
||||
|
||||
// Avoid: record types for DTOs
|
||||
public record WorkOrderViewModel(string OrderNumber, DateTime? CompletionDate);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Code Review: JdeScoping.Api
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.Api`
|
||||
**Layer:** Presentation
|
||||
**Purpose:** ASP.NET Core Web API, controllers, SignalR hubs
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.Api project is a well-structured ASP.NET Core Web API presentation layer. The codebase demonstrates good adherence to modern .NET practices, proper use of dependency injection, and clean architecture principles.
|
||||
|
||||
**Overall Assessment: Good** with areas for improvement
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Minor SRP violations in some controllers |
|
||||
| Clean Architecture | Excellent | Proper layer separation |
|
||||
| Code Organization | Good | Well-organized partial classes |
|
||||
| Error Handling | Needs Improvement | Inconsistent patterns |
|
||||
| Testability | Good | DI used throughout |
|
||||
| Code Duplication | Needs Improvement | DRY violations in FileIOController |
|
||||
| Async Patterns | Good | Mostly correct usage |
|
||||
| Null Safety | Good | Nullable enabled, some gaps |
|
||||
| Documentation | Excellent | Comprehensive XML comments |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**✅ FIXED: PipelineController mapping logic extracted**
|
||||
|
||||
The `PipelineController` mapping logic has been extracted to a dedicated `IPipelineMapper` interface and `PipelineMapper` implementation in the `Mapping/` folder. The controller now delegates to the injected mapper.
|
||||
|
||||
**Issue: RefreshStatusController performs data aggregation**
|
||||
|
||||
The controller performs complex LINQ aggregation that belongs in a service layer.
|
||||
|
||||
**Recommendation:** Move aggregation logic to a service class.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Positive:** All controllers properly depend on abstractions via constructor injection.
|
||||
|
||||
### Open/Closed Principle (OCP)
|
||||
|
||||
**Issue:** `StatusHub` uses static mutable state which is problematic for testability.
|
||||
|
||||
**Recommendation:** Replace static state with `IMemoryCache` or a dedicated caching service.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
### Excellent Practices
|
||||
|
||||
1. **Proper dependency direction:** Api project depends on Core (interfaces) and Infrastructure
|
||||
2. **Centralized route definitions:** Routes defined in `ApiRoutes` class in Core
|
||||
3. **Base controller abstraction:** `ApiControllerBase` provides common functionality
|
||||
|
||||
### Minor Violations
|
||||
|
||||
**✅ FIXED:** `PipelineController` now inherits from `ApiControllerBase`, consistent with other controllers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Positive Observations
|
||||
|
||||
1. **Good use of partial classes** for FileIOController
|
||||
2. **Logical folder structure:** Controllers, Extensions, Hubs, Models, Options, Services
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Critical Issues
|
||||
|
||||
**Issue: Inconsistent error response patterns in FileIOController**
|
||||
|
||||
Returning `200 OK` for error conditions violates HTTP semantics:
|
||||
|
||||
```csharp
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<WorkOrderViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Return `BadRequest()` for validation failures.
|
||||
|
||||
### Positive Error Handling
|
||||
|
||||
The `SearchNotificationService` correctly implements best-effort notification pattern with logging.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
### Positive Aspects
|
||||
|
||||
1. Constructor injection throughout
|
||||
2. Interface-based dependencies
|
||||
3. Proper service registration
|
||||
|
||||
### Issues Affecting Testability
|
||||
|
||||
1. Static state in `StatusHub`
|
||||
2. Direct `DateTime.UtcNow` usage - inject `TimeProvider` for testability
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication (DRY Violations)
|
||||
|
||||
### Critical: Repetitive upload handling pattern
|
||||
|
||||
The upload methods follow identical patterns repeated 4 times in FileIOController.
|
||||
|
||||
**Recommendation:** Extract to a generic handler method.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
### Positive Observations
|
||||
|
||||
1. Proper async/await usage
|
||||
2. CancellationToken propagation
|
||||
3. Correct use of `await using` for streams
|
||||
|
||||
### Issues
|
||||
|
||||
**Issue:** `PartOperations` upload method is synchronous unlike other upload methods.
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
### Positive: Nullable reference types enabled
|
||||
|
||||
### Issues
|
||||
|
||||
~~**Issue:** Null-forgiving operator usage without validation in SearchController.~~ ✅ FIXED
|
||||
|
||||
~~**Recommendation:** Add explicit null validation:~~
|
||||
```csharp
|
||||
if (CurrentUserName is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
```
|
||||
**Status:** Explicit null checks have been added to all methods in `SearchController` that use `CurrentUserName`. Methods now return `Unauthorized()` if the claim is missing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
### Excellent: Comprehensive XML documentation
|
||||
|
||||
All public APIs have XML documentation with `ProducesResponseType` attributes.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Critical Priority
|
||||
1. Fix error response status codes in FileIOController
|
||||
2. Remove static state from StatusHub
|
||||
3. ~~Extract mapping logic from PipelineController~~ ✅ FIXED - Extracted to `IPipelineMapper`/`PipelineMapper` in `Mapping/` folder
|
||||
|
||||
### High Priority
|
||||
4. Extract aggregation logic from RefreshStatusController
|
||||
5. Reduce code duplication in FileIOController
|
||||
6. ~~Add null validation before using null-forgiving operators~~ ✅ FIXED
|
||||
|
||||
### Medium Priority
|
||||
7. ~~Make PipelineController inherit from ApiControllerBase~~ ✅ FIXED
|
||||
8. Make PartOperations upload method async
|
||||
9. Replace direct DateTime.UtcNow usage with TimeProvider
|
||||
@@ -0,0 +1,210 @@
|
||||
# Code Review: JdeScoping.Client
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.Client`
|
||||
**Layer:** Presentation
|
||||
**Purpose:** Blazor WebAssembly client
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.Client project is a Blazor WebAssembly application serving as the presentation layer. The codebase demonstrates good architectural patterns with clear separation between services, components, and models.
|
||||
|
||||
**Overall Assessment: Good with Room for Improvement**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Some SRP concerns in SearchEdit.razor |
|
||||
| Clean Architecture | Excellent | Clear layer separation |
|
||||
| Code Organization | Good | Well-organized folder structure |
|
||||
| Error Handling | Good | Consistent ApiResult pattern |
|
||||
| Testability | Good | Interface-based design enables mocking |
|
||||
| Code Duplication | ~~Needs Improvement~~ Good | ~~DRY violations in filter panels~~ ✅ FIXED |
|
||||
| Async Patterns | Good | Proper async/await usage |
|
||||
| Null Safety | Good | Nullable enabled with proper handling |
|
||||
| Documentation | Good | XML docs on interfaces and services |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Positive Observations:**
|
||||
- API clients properly separated by domain
|
||||
- Authentication concerns separated into dedicated services
|
||||
- Base class `ApiClientBase` handles HTTP concerns separately
|
||||
|
||||
**Areas for Improvement:**
|
||||
|
||||
**Issue: SearchEdit.razor has multiple responsibilities**
|
||||
|
||||
The component handles:
|
||||
- Search loading and state management
|
||||
- Form validation
|
||||
- Search type detection
|
||||
- SignalR event handling
|
||||
- File download operations
|
||||
|
||||
**Recommendation:** Extract search type detection into a dedicated service.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Issue: Concrete dependency on AuthStateProvider**
|
||||
|
||||
**Recommendation:** Create an `IAuthStateProvider` interface.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
### Layer Separation
|
||||
|
||||
**Positive Observations:**
|
||||
- Clear folder structure separating Pages, Components, Services, Models
|
||||
- Core contracts defined in separate `JdeScoping.Core` project
|
||||
- ViewModels properly isolated in Models folder
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Positive:** Consistent PascalCase for public members, underscore prefix for private fields.
|
||||
|
||||
**Issue:** Inconsistent parameter naming in TimeSpanFilterPanel (MinimumDT vs MinimumDt).
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Positive: Consistent use of `ApiResult<T>` discriminated union
|
||||
|
||||
Standard pattern:
|
||||
```csharp
|
||||
result.Switch(
|
||||
success => { /* handle success */ },
|
||||
notFound => { _errorMessage = "Not found."; },
|
||||
validation => { _errorMessage = FormatValidationErrors(validation.FieldErrors); },
|
||||
// ...
|
||||
);
|
||||
```
|
||||
|
||||
### Issues
|
||||
|
||||
~~**Issue: Silent exception swallowing in AuthStateProvider**~~ ✅ FIXED
|
||||
|
||||
**Resolution:** Exceptions are now logged at Warning level with the message "Session validation failed, treating as unauthenticated".
|
||||
|
||||
**Issue: RefreshStatusService uses Console.WriteLine**
|
||||
|
||||
**Recommendation:** Use proper logging abstraction.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
### Positive Aspects
|
||||
|
||||
- All services properly registered in DI
|
||||
- Interface-based design for API clients
|
||||
- Comprehensive test coverage in ApiClientBaseTests
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication (DRY Violations)
|
||||
|
||||
### Critical Duplications
|
||||
|
||||
**Issue: FormatSql method duplicated**
|
||||
|
||||
Found in both `PipelineScheduleSection.razor` and `SqlQueryModal.razor`.
|
||||
|
||||
**Recommendation:** Extract to a shared utility class like `SqlFormatHelper.cs`.
|
||||
|
||||
**~~Issue: Duplicated API result handling in filter panels~~** ✅ FIXED
|
||||
|
||||
**~~Issue: Similar autocomplete search patterns~~** ✅ FIXED
|
||||
|
||||
~~Multiple filter panels have nearly identical `OnSearchAsync`, `OnItemSelected`, `AddItemAsync`, `DeleteItem`, `ClearDataAsync` methods.~~
|
||||
|
||||
**Resolution:** Created two base component classes:
|
||||
- `AutocompleteFilterPanelBase<TItem>` - for panels with autocomplete search (WorkCenter, ProfitCenter, Operator)
|
||||
- `FileUploadFilterPanelBase<TItem>` - for panels with file upload (WorkOrder, ComponentLot, PartOperation)
|
||||
|
||||
Derived components now inherit from these bases and only override abstract methods for panel-specific API calls and configuration.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
### Positive Observations
|
||||
|
||||
- Consistent async/await usage
|
||||
- CancellationToken support in all API client methods
|
||||
- Proper use of `Task.WhenAll` for parallel requests
|
||||
|
||||
### Issues
|
||||
|
||||
**Issue: Fire-and-forget tasks without error handling**
|
||||
|
||||
```csharp
|
||||
_ = JSRuntime.InvokeVoidAsync("downloadFile", "template.xlsx", bytes);
|
||||
```
|
||||
|
||||
**Recommendation:** Consider awaiting these calls or using a try-catch wrapper.
|
||||
|
||||
**Positive:** Good thread-safe caching in CryptoService with SemaphoreSlim.
|
||||
|
||||
**~~Issue:~~ SemaphoreSlim not disposed** ✅ FIXED - Implemented `IAsyncDisposable` on CryptoService.
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
### Positive: Nullable reference types enabled
|
||||
|
||||
**Issues:**
|
||||
|
||||
**Issue: Nullable parameters not validated in ViewModelMappingExtensions**
|
||||
|
||||
**Recommendation:** Add null checks or use null-conditional operator.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security Considerations
|
||||
|
||||
### Positive Practices
|
||||
|
||||
- Good credential encryption pattern using RSA-OAEP
|
||||
|
||||
### Issues
|
||||
|
||||
**Issue: Potential XSS via JavaScript eval**
|
||||
|
||||
```csharp
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('workOrderFileInput').click()");
|
||||
```
|
||||
|
||||
**Recommendation:** Create a dedicated JavaScript interop function instead.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Critical Priority
|
||||
1. Extract `FormatSql` method to shared utility class
|
||||
2. Replace `eval` usage with dedicated JS interop function
|
||||
|
||||
### High Priority
|
||||
3. Extract business logic from `SearchEdit.razor` into services
|
||||
4. ~~Create generic base for autocomplete filter panels~~ ✅ FIXED
|
||||
5. ~~Add logging to catch blocks~~ ✅ FIXED - Added Warning-level logging to `AuthStateProvider`
|
||||
6. ~~Implement `IAsyncDisposable` on `CryptoService`~~ ✅ FIXED
|
||||
|
||||
### Medium Priority
|
||||
7. Create interface for `AuthStateProvider`
|
||||
8. Standardize API result error notification handling
|
||||
9. Use `IAuthApiClient` in `CryptoService` instead of direct HTTP
|
||||
10. Add XML documentation to Razor components
|
||||
@@ -0,0 +1,201 @@
|
||||
# Code Review: JdeScoping.Core
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.Core`
|
||||
**Layer:** Domain
|
||||
**Purpose:** Core models, DTOs, interfaces
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.Core project is the domain layer containing core models, DTOs, interfaces, and shared contracts. The codebase demonstrates **good architectural practices** with clear separation of concerns, proper use of nullable reference types, and consistent documentation.
|
||||
|
||||
**Overall Grade: B+**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Minor ISP violation |
|
||||
| Clean Architecture | Excellent | No inappropriate dependencies |
|
||||
| Code Organization | Excellent | Well-organized structure |
|
||||
| Error Handling | Good | `TryGetCriteria` method with logging support |
|
||||
| Testability | Excellent | Interface-based design |
|
||||
| Code Duplication | Fair | Repeated JDE date conversion pattern |
|
||||
| Async Patterns | Good | Consistent naming, CancellationToken support |
|
||||
| Null Safety | Good | Nullable enabled with proper annotations |
|
||||
| Documentation | Excellent | Comprehensive XML documentation |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Positive:** Most classes adhere well to SRP - each model represents a single domain concept.
|
||||
|
||||
### Interface Segregation Principle (ISP)
|
||||
|
||||
~~**Issue: IExcelExportService uses `object` parameter**~~ ✅ FIXED
|
||||
|
||||
~~```csharp
|
||||
public interface IExcelExportService
|
||||
{
|
||||
Task<byte[]> GenerateAsync(object search, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```~~
|
||||
|
||||
~~**Recommendation:** Change to use strongly-typed `SearchModel` parameter.~~
|
||||
|
||||
**Resolution:** Changed `IExcelExportService.GenerateAsync` to accept `Core.Models.SearchResults.SearchModel` instead of `object`. The `ExcelExportService` implementation maps to its internal `ExcelIO.Models.Reporting.SearchModel` for Excel generation.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Positive:** Service interfaces properly defined in Core layer with clean abstractions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
### Layer Separation
|
||||
|
||||
**Excellent:** Core project maintains proper architectural boundaries:
|
||||
- No references to infrastructure concerns
|
||||
- Only depends on abstraction packages
|
||||
- Defines contracts that outer layers implement
|
||||
|
||||
### ApiContracts Placement
|
||||
|
||||
The `ApiContracts` folder containing API client interfaces is appropriately placed in Core.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Folder Structure
|
||||
|
||||
**Excellent:**
|
||||
```
|
||||
JdeScoping.Core/
|
||||
├── ApiContracts/Results/
|
||||
├── Helpers/
|
||||
├── Interfaces/
|
||||
├── Models/
|
||||
│ ├── Auth/, Enums/, Infrastructure/
|
||||
│ ├── Inventory/, Lookup/, Organization/
|
||||
│ ├── Pipelines/, Quality/, Search/
|
||||
│ ├── SearchResults/, WorkOrders/
|
||||
├── Options/
|
||||
└── ViewModels/
|
||||
```
|
||||
|
||||
### Partial Classes for Repository
|
||||
|
||||
Using partial classes to split `ILotFinderRepository` by functional area is a good organizational choice.
|
||||
|
||||
### Issues
|
||||
|
||||
**Issue: Inconsistent Record/Class Usage**
|
||||
|
||||
The codebase mixes classes and records inconsistently for DTOs.
|
||||
|
||||
**Recommendation:** Establish consistent pattern - use records for immutable DTOs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
~~**Issue: Silent Deserialization Failure**~~ ✅ FIXED
|
||||
|
||||
**Resolution:** Added `TryGetCriteria(out SearchCriteria? criteria, ILogger? logger = null)` method that:
|
||||
- Returns `true` if deserialization succeeded or JSON was empty
|
||||
- Returns `false` if deserialization failed
|
||||
- Logs at Warning level when logger is provided
|
||||
- Property getter uses the method without logging for backwards compatibility
|
||||
- Services that need logging can call `TryGetCriteria` directly with their logger
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
**Excellent:** Interface-based design with all services defined as interfaces, enabling easy mocking.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
### DRY Violation in Domain Models
|
||||
|
||||
Multiple domain models contain identical patterns for JDE date/time conversion:
|
||||
|
||||
```csharp
|
||||
private int LastUpdateDate { get; set; }
|
||||
private int LastUpdateTime { get; set; }
|
||||
public DateTime? LastUpdateDt => JdeDateConverter.ToDateTime(LastUpdateDate, LastUpdateTime);
|
||||
```
|
||||
|
||||
Pattern repeated in 13+ files.
|
||||
|
||||
**Recommendation:** Document why this pattern is intentionally repeated (e.g., for Dapper mapping compatibility).
|
||||
|
||||
### Duplicate ViewModel Classes
|
||||
|
||||
`LotViewModel` and `ComponentLotViewModel` have identical properties.
|
||||
|
||||
**Recommendation:** Consolidate or have `ComponentLotViewModel` inherit from `LotViewModel`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
**Good:** Consistent `Async` suffix naming, all async interface methods include `CancellationToken` parameters with defaults.
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
**Good:** Nullable reference types enabled with proper annotations.
|
||||
|
||||
**Issue: Inconsistent Null Guard in Constructor**
|
||||
|
||||
```csharp
|
||||
public SearchViewModel(Search search)
|
||||
{
|
||||
if (search == null) return; // Silent return on null
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Throw `ArgumentNullException` or use factory method pattern.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
**Excellent:** Nearly all public members have XML documentation with `<summary>`, `<param>`, and `<returns>` tags.
|
||||
|
||||
---
|
||||
|
||||
## 10. Additional Observations
|
||||
|
||||
### Well-Designed API Result Pattern
|
||||
|
||||
The `ApiResult<T>` discriminated union using OneOf provides compile-time safety for error handling.
|
||||
|
||||
### Data Type Mismatch
|
||||
|
||||
`AddressNumber` is `long` in `JdeUser` but `int` in `OperatorViewModel` - potential data loss.
|
||||
|
||||
**Recommendation:** Change `OperatorViewModel.AddressNumber` to `long`.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Important
|
||||
1. ~~Fix `IExcelExportService` interface to use strongly-typed parameter~~ ✅ FIXED
|
||||
2. Consolidate duplicate ViewModels
|
||||
3. Fix `AddressNumber` type mismatch
|
||||
4. ~~Add logging to `Search.Criteria` deserialization~~ ✅ FIXED - Added `TryGetCriteria` method with logging support
|
||||
|
||||
### Suggestions
|
||||
1. Document JDE date pattern rationale
|
||||
2. Establish guideline for records vs classes for DTOs
|
||||
3. Fix null guard in `SearchViewModel` constructor
|
||||
@@ -0,0 +1,205 @@
|
||||
# Code Review: JdeScoping.DataAccess
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.DataAccess`
|
||||
**Layer:** Infrastructure
|
||||
**Purpose:** Repositories, Dapper queries
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.DataAccess project is a well-structured infrastructure layer implementing the repository pattern with Dapper for data access. The code demonstrates solid architectural decisions, good separation of concerns, and proper use of modern C# features.
|
||||
|
||||
**Overall Assessment: Good with room for improvement**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Minor SRP concerns in SearchProcessor |
|
||||
| Clean Architecture | Excellent | Proper layer separation |
|
||||
| Code Organization | Good | Well-organized partial classes |
|
||||
| Error Handling | Good | Consistent exception hierarchy |
|
||||
| Testability | Very Good | Interface-based design enables mocking |
|
||||
| Code Duplication | ~~Needs Improvement~~ Good | ~~Repetitive patterns in repository~~ ✅ FIXED |
|
||||
| Async Patterns | Good | Proper async/await usage |
|
||||
| Null Safety | Needs Improvement | Inconsistent nullable handling |
|
||||
| Documentation | Very Good | Comprehensive XML comments |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Positive:** Clear separation between `LotFinderRepository`, `SearchProcessor`, and `WorkOrderTraversalService`.
|
||||
|
||||
**Issue: SearchProcessor has multiple responsibilities**
|
||||
|
||||
Handles search query execution, MIS data extraction, debug SQL file writing, and MisQueryBuilder instantiation.
|
||||
|
||||
**Recommendation:** Inject `MisQueryBuilder` through DI rather than creating internally.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Positive:** All major components depend on abstractions.
|
||||
|
||||
**Issue:** `MisQueryBuilder` is not registered in DI.
|
||||
|
||||
**Recommendation:** Create `IMisQueryBuilder` interface and register in DI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
**Excellent:** DataAccess project only references Core project with correct dependency direction.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Folder Structure
|
||||
|
||||
**Good:**
|
||||
```
|
||||
JdeScoping.DataAccess/
|
||||
├── Exceptions/ # Custom exception types
|
||||
├── Interfaces/ # Internal interfaces
|
||||
├── Models/ # DTOs for queries
|
||||
├── Options/ # Configuration classes
|
||||
├── Queries/ # SQL query constants
|
||||
├── QueryBuilders/ # Dynamic query construction
|
||||
├── Repositories/ # Repository implementations
|
||||
└── Services/ # Domain services
|
||||
```
|
||||
|
||||
### Partial Class Usage
|
||||
|
||||
**Good:** Repository organized using partial classes by domain.
|
||||
|
||||
### Configuration Class Overlap
|
||||
|
||||
**Issue:** `SearchProcessingOptions` and `SearchProcessingConfiguration` both use section name `"SearchProcessing"`.
|
||||
|
||||
**Recommendation:** Consolidate into a single options class or use distinct section names.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Exception Hierarchy
|
||||
|
||||
**Excellent:**
|
||||
```
|
||||
DataAccessException (base)
|
||||
├── ConnectionException
|
||||
├── QueryException
|
||||
└── DataAccessTimeoutException
|
||||
```
|
||||
|
||||
### Consistent Error Handling Pattern
|
||||
|
||||
**Good:** Centralized `LogAndThrow` method used throughout repository.
|
||||
|
||||
**Issue:** Missing try-catch in some service methods (`WorkOrderTraversalService`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
**Excellent:** All public APIs are interface-based with constructor injection.
|
||||
|
||||
**Issue:** Inconsistent guard clauses - some classes have null guards, others do not.
|
||||
|
||||
**Recommendation:** Add consistent null guards to all constructors.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
### ~~Repetitive try-catch pattern~~ ✅ FIXED
|
||||
|
||||
~~Pattern repeated 10+ times in repository methods.~~
|
||||
|
||||
**Resolution:** Extracted generic `ExecuteQueryAsync<T>` method to `LotFinderRepository.cs`. All 13 repository methods in `SearchManagement.cs`, `Lookups.cs`, and `DataSync.cs` now use this helper method, reducing boilerplate from ~84 lines to 14 lines.
|
||||
|
||||
### Duplicate Code in SearchProcessor
|
||||
|
||||
`ExecuteSearchAsync` and `ExecuteSearchToModelAsync` share significant code.
|
||||
|
||||
**Recommendation:** Extract common logic into a private setup method.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
### Positive Observations
|
||||
|
||||
- All database operations use async methods
|
||||
- Proper `ConfigureAwait(false)` in infrastructure code
|
||||
- IAsyncEnumerable support for streaming large result sets
|
||||
|
||||
### Issues
|
||||
|
||||
**Issue:** Potential connection pooling concern - callers must dispose connections.
|
||||
|
||||
**Recommendation:** Document clearly or consider connection scope pattern.
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
**Good:** Nullable reference types enabled.
|
||||
|
||||
**Issues:**
|
||||
|
||||
- Some nullable string parameters not validated in SQL queries
|
||||
- Missing null/empty validation in repository methods
|
||||
|
||||
**Recommendation:** Add validation:
|
||||
```csharp
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
return [];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
**Excellent:** Nearly all public members have XML documentation, SQL queries include inline comments.
|
||||
|
||||
---
|
||||
|
||||
## 10. Additional Observations
|
||||
|
||||
### Magic Numbers
|
||||
|
||||
Extremely large timeout values (999999 seconds) seem arbitrary.
|
||||
|
||||
**Recommendation:** Use more reasonable defaults or document rationale.
|
||||
|
||||
### Hardcoded Command Timeout
|
||||
|
||||
`WorkOrderTraversalService` uses hardcoded timeout instead of configuration.
|
||||
|
||||
### Redundant throw after LogAndThrow
|
||||
|
||||
**Recommendation:** Add `[DoesNotReturn]` attribute to `LogAndThrow` method.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Critical
|
||||
1. Add `[DoesNotReturn]` attribute to `LogAndThrow`
|
||||
2. Register `MisQueryBuilder` in DI
|
||||
|
||||
### Important
|
||||
3. Add null guards to `SearchProcessor` constructor
|
||||
4. Consolidate overlapping configuration classes
|
||||
5. Extract repetitive try-catch pattern
|
||||
6. Inject timeout configuration into `WorkOrderTraversalService`
|
||||
|
||||
### Suggestions
|
||||
7. Add validation for search filter parameters
|
||||
8. Use `IReadOnlyList<T>` for query result return types
|
||||
9. Extract common setup logic from SearchProcessor methods
|
||||
10. Create `IMisQueryBuilder` interface for testability
|
||||
@@ -0,0 +1,184 @@
|
||||
# Code Review: JdeScoping.DataSync
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.DataSync`
|
||||
**Layer:** Application
|
||||
**Purpose:** ETL pipelines, transformers
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.DataSync project is a well-structured application layer component responsible for ETL (Extract-Transform-Load) pipelines and data synchronization between enterprise systems (JDE/CMS) and a local SQL Server cache. The codebase demonstrates solid architectural patterns and generally follows best practices.
|
||||
|
||||
**Overall Assessment: Good (8.5/10)**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Strong adherence |
|
||||
| Clean Architecture | Good | Proper layer separation |
|
||||
| Code Organization | Excellent | Logical folder structure |
|
||||
| Error Handling | Good | Consistent patterns |
|
||||
| Testability | Good | Interface-based design |
|
||||
| Code Duplication | Good | Minor DRY violations |
|
||||
| Async Patterns | Excellent | Proper async/await |
|
||||
| Null Safety | Excellent | Nullable enabled |
|
||||
| Documentation | Good | Comprehensive XML docs |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Good:** Clear separation of concerns:
|
||||
- `WorkProcessor` focuses on orchestrating the work loop
|
||||
- `SyncOrchestrator` handles parallel execution coordination
|
||||
- `ScheduleChecker` determines pending tasks
|
||||
- `TableSyncOperation` executes individual sync operations
|
||||
- ETL components have focused responsibilities
|
||||
|
||||
### Open/Closed Principle (OCP)
|
||||
|
||||
**Excellent:** ETL pipeline architecture is highly extensible:
|
||||
- New transformers via `IDataTransformer`
|
||||
- New data sources via `IImportSource`
|
||||
- New destinations via `IImportDestination`
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Excellent:** All dependencies injected via interfaces.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
### Layer Separation
|
||||
|
||||
**Good:** Proper dependency direction:
|
||||
```
|
||||
JdeScoping.DataSync
|
||||
-> JdeScoping.Core (domain models, interfaces)
|
||||
-> JdeScoping.DataAccess (database abstractions)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Folder Structure
|
||||
|
||||
**Excellent:**
|
||||
```
|
||||
JdeScoping.DataSync/
|
||||
├── Configuration/ # JSON config mapping
|
||||
├── Contracts/ # Internal interfaces
|
||||
├── Etl/
|
||||
│ ├── Contracts/ # ETL abstractions
|
||||
│ ├── Destinations/ # Bulk import/merge
|
||||
│ ├── Pipeline/ # Pipeline orchestration
|
||||
│ ├── Results/ # Result types
|
||||
│ ├── Scripts/ # SQL script runners
|
||||
│ ├── Sources/ # Data source implementations
|
||||
│ └── Transformers/ # Data transformation
|
||||
├── HealthChecks/
|
||||
├── Models/
|
||||
├── Options/
|
||||
├── Pipelines/ # JSON configuration
|
||||
├── Services/
|
||||
└── Telemetry/ # Metrics and tracing
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Issue:** Duplicate `ScheduleConfig` class names in different namespaces.
|
||||
|
||||
**Recommendation:** Rename one to avoid confusion.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Proper Patterns
|
||||
|
||||
1. **Graceful cancellation handling** in `WorkProcessor`
|
||||
2. **Safe notification methods** that don't throw
|
||||
3. **Distinguishing timeout vs shutdown** cancellation
|
||||
|
||||
### Issues
|
||||
|
||||
**~~Issue:~~ Silent exception swallowing in `DbBulkMergeDestination.DropTempTableAsync`** ✅ FIXED
|
||||
|
||||
**Resolution:** Added optional `ILogger<DbBulkMergeDestination>` parameter to constructor and replaced silent catch with `_logger?.LogDebug(ex, "Failed to drop temporary table {TempTableName} during cleanup", tempTableName)`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
**Excellent:** All services use constructor injection with interfaces.
|
||||
|
||||
### Test Coverage
|
||||
|
||||
Comprehensive tests exist for:
|
||||
- `WorkProcessorTests`
|
||||
- `SyncOrchestratorTests`
|
||||
- `ScheduleCheckerTests`
|
||||
- `EtlPipelineTests`
|
||||
- All transformer types
|
||||
- Repository tests
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
### Column Retrieval Pattern
|
||||
|
||||
**Issue:** `GetDestinationColumnsAsync` duplicated in `DbBulkImportDestination` and `DbBulkMergeDestination`.
|
||||
|
||||
**Recommendation:** Extract to a shared utility method in `CommonScripts`.
|
||||
|
||||
**Positive:** Good abstractions like `CommonScripts.ParseTableName` and `DataTransformerBase`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
**Excellent:**
|
||||
|
||||
1. **Proper async disposal:** `await using var scope = ...`
|
||||
2. **Correct parallel pattern:** `Parallel.ForEachAsync` with async lambda
|
||||
3. **Linked cancellation tokens** for timeout handling
|
||||
4. **CancellationToken.None** for cleanup operations
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
**Excellent:** Nullable reference types enabled with proper handling:
|
||||
|
||||
- Guard clauses: `ArgumentNullException.ThrowIfNull(...)`
|
||||
- Nullable return types properly declared
|
||||
- Required properties with proper initialization
|
||||
- Null-forgiving operator used appropriately
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
**Good:** All public interfaces have comprehensive XML documentation with examples.
|
||||
|
||||
**Issue:** TODO comment in `EtlPipelineBuilder` should be addressed or converted to documentation.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Important
|
||||
1. Resolve duplicate `ScheduleConfig` class names
|
||||
2. Extract shared column retrieval logic
|
||||
3. ~~Add logging to silent catch blocks~~ ✅ FIXED
|
||||
|
||||
### Suggestions
|
||||
1. Consider separating configuration loading from `EtlPipelineFactory`
|
||||
2. Document TODO in `EtlPipelineBuilder.WithCommandTimeout`
|
||||
3. Add XML documentation to `PipelineBuilder` nested class
|
||||
4. Consider shared constants class for database status markers
|
||||
@@ -0,0 +1,160 @@
|
||||
# Code Review: JdeScoping.DataSync.Dev
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.DataSync.Dev`
|
||||
**Layer:** Application
|
||||
**Purpose:** Dev-only ETL tooling
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.DataSync.Dev project is a well-structured application layer providing development-only ETL tooling for loading cached protobuf data into SQL Server. The codebase demonstrates strong adherence to clean architecture principles and good separation of concerns.
|
||||
|
||||
**Overall Rating: Excellent**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Strong adherence |
|
||||
| Clean Architecture | Excellent | Proper layer separation |
|
||||
| Code Organization | Excellent | Logical folder structure |
|
||||
| Error Handling | Good | Consistent patterns |
|
||||
| Testability | Good | Well-designed for testing |
|
||||
| Code Duplication | Excellent | No significant violations |
|
||||
| Async Patterns | Good | Proper async/await |
|
||||
| Null Safety | Excellent | Nullable enabled |
|
||||
| Documentation | Good | XML comments present |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Good:**
|
||||
- `DevEtlRegistry` focuses on orchestrating pipeline execution
|
||||
- `ProtobufZstdFileSource` handles only file-based data reading
|
||||
- `DevEtlPipelineFactory` is responsible for pipeline construction
|
||||
|
||||
**Observation:** `DevEtlPipelineFactory` handles both configuration loading and pipeline creation - acceptable for this scope.
|
||||
|
||||
### Open/Closed Principle (OCP)
|
||||
|
||||
**Excellent:** The use of interfaces and builder pattern allows extension without modification.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Excellent:** All dependencies injected via interfaces.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
### Layer Separation
|
||||
|
||||
**Excellent:**
|
||||
```
|
||||
JdeScoping.DataSync.Dev/
|
||||
├── Configuration/ # DTOs for JSON config
|
||||
├── Contracts/ # Interface definitions
|
||||
├── Options/ # Options pattern classes
|
||||
├── Services/ # Implementations
|
||||
├── Sources/ # IImportSource implementations
|
||||
└── Pipelines/ # JSON configuration files
|
||||
```
|
||||
|
||||
### Dependency Direction
|
||||
|
||||
**Correct:** Dev project depends on DataSync for core infrastructure but not vice versa.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
**Excellent:** Logical organization by responsibility with clear naming conventions.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Strengths
|
||||
|
||||
1. **Argument Validation:** Modern patterns like `ArgumentException.ThrowIfNullOrWhiteSpace`
|
||||
2. **Constructor Validation:** Directory existence checks
|
||||
3. **Descriptive Error Messages:** Include available options
|
||||
|
||||
### Minor Concern
|
||||
|
||||
Bare `catch` clause in `ProtobufZstdFileSource.ReadDataAsync` catches all exceptions.
|
||||
|
||||
**Recommendation:** Consider `catch (Exception ex) when (...)` pattern.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
### Strengths
|
||||
|
||||
1. Interface-based design enables mocking
|
||||
2. Internal constructor for testing
|
||||
3. `InternalsVisibleTo` for test assembly
|
||||
|
||||
**Observation:** Logger is optional (`ILogger<T>? logger = null`) - consider always requiring logger and using `NullLogger<T>.Instance` in tests.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
**Excellent:** No significant violations.
|
||||
|
||||
~~**Minor:** `GetAvailableTables()` called twice in `RunAllParallelAsync` - could be optimized.~~ ✅ FIXED - Cached result in local variable.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
### Strengths
|
||||
|
||||
- Proper async/await throughout
|
||||
- CancellationToken propagation
|
||||
- Parallel execution with `SemaphoreSlim` throttling
|
||||
|
||||
### Observation
|
||||
|
||||
`ProtobufZstdFileSource.ReadDataAsync` is synchronous despite returning `Task<IDataReader>`. This is acceptable because:
|
||||
1. Interface requires Task for uniformity
|
||||
2. File I/O with SequentialScan is generally fast
|
||||
3. Actual reading happens lazily
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
**Excellent:**
|
||||
- Nullable reference types enabled
|
||||
- Disposable fields marked nullable
|
||||
- Null-conditional logging
|
||||
- Null-coalescing for configuration defaults
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
**Good:** All public types have XML documentation.
|
||||
|
||||
~~**Suggestion:** Add project README explaining dev tooling setup and usage.~~ ✅ FIXED - Added comprehensive README.md
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Suggestions
|
||||
1. ~~Optimize double enumeration in `RunAllParallelAsync`~~ ✅ FIXED - Cached `GetAvailableTables()` result
|
||||
2. Use exception filter on cleanup catch block
|
||||
3. ~~Add project README for developer onboarding~~ ✅ FIXED - Added README.md
|
||||
|
||||
### Positive Highlights
|
||||
1. Excellent builder pattern usage
|
||||
2. Strong interface design
|
||||
3. Intelligent parallelization (large tables run sequentially)
|
||||
4. Clean configuration binding using records
|
||||
5. Proper resource management with `IAsyncDisposable`
|
||||
@@ -0,0 +1,174 @@
|
||||
# Code Review: JdeScoping.Database
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.Database`
|
||||
**Layer:** Infrastructure
|
||||
**Purpose:** SQL Server schema, migrations
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.Database project is a well-structured database migration and schema management project using DbUp for SQL Server. The project demonstrates solid foundational practices with room for improvement in testability and abstraction.
|
||||
|
||||
**Overall Assessment: Good with targeted improvements recommended**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Fair | Missing interface abstraction for migrator |
|
||||
| Clean Architecture | Good | Clear infrastructure layer responsibilities |
|
||||
| Code Organization | Good | Logical folder structure with numbered scripts |
|
||||
| Error Handling | Good | Consistent patterns in SQL; C# could improve |
|
||||
| Testability | Fair | Concrete class instantiation limits mocking |
|
||||
| Code Duplication | Fair | Significant duplication in SQL scripts |
|
||||
| Async Patterns | N/A | Synchronous by design (DbUp limitation) |
|
||||
| Null Safety | Good | Nullable enabled with proper checks |
|
||||
| Documentation | Good | SQL comments present; C# XML docs sparse |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Good:** `DatabaseMigrator` class has a single, clear responsibility.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Issue:** `DatabaseMigrator` is a concrete class without an interface abstraction.
|
||||
|
||||
**Recommendation:** Extract an interface:
|
||||
```csharp
|
||||
public interface IDatabaseMigrator
|
||||
{
|
||||
DatabaseUpgradeResult Migrate();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
**Good:** The project correctly sits in the Infrastructure layer with minimal dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Folder Structure
|
||||
|
||||
**Good:**
|
||||
```
|
||||
JdeScoping.Database/
|
||||
DatabaseMigrator.cs
|
||||
Scripts/
|
||||
001_CreateSearchTable.sql
|
||||
002_CreateDataUpdateTable.sql
|
||||
... (48 numbered scripts)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Good:** Clear, descriptive names following pattern `NNN_VerbObjectDescription.sql`.
|
||||
|
||||
SQL object prefixes:
|
||||
- Stored Procedures: `usp_` prefix
|
||||
- Functions: `fn_` prefix
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### SQL Error Handling
|
||||
|
||||
**Good:** Stored procedures use custom error codes with `THROW`:
|
||||
|
||||
```sql
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
SET @ErrorMsg = CONCAT('Search ID ', @SearchId, ' not found');
|
||||
THROW 50001, @ErrorMsg, 1;
|
||||
END
|
||||
```
|
||||
|
||||
### C# Error Handling
|
||||
|
||||
**Recommendation:** Add exception wrapping to provide better context on migration failures.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
**Issue:** Direct instantiation with `new DatabaseMigrator(...)` prevents unit testing.
|
||||
|
||||
**Recommendation:**
|
||||
1. Add `IDatabaseMigrator` interface
|
||||
2. Register in DI container
|
||||
3. Allow mock injection for testing
|
||||
|
||||
**Positive:** The test infrastructure is well-designed with automatic test data cleanup.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
### Table Definition Duplication
|
||||
|
||||
`_Curr` and `_Hist` table pairs have identical column definitions duplicated across multiple files.
|
||||
|
||||
**Note:** This is typical for SQL migration files but consider documenting canonical column definitions in a single reference location.
|
||||
|
||||
### MisData Triple Duplication
|
||||
|
||||
Files 012 and 012a both create `MisData_Hist` with identical DDL - appears to be refactoring artifact.
|
||||
|
||||
**Recommendation:** Clean up redundant migration script.
|
||||
|
||||
---
|
||||
|
||||
## 7. Null Safety
|
||||
|
||||
**Good:** Project enables nullable reference types with proper null-coalescing throw pattern:
|
||||
|
||||
```csharp
|
||||
_connectionString = configuration.GetConnectionString("SqlServer")
|
||||
?? throw new InvalidOperationException("SqlServer connection string not configured");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
### SQL Script Documentation
|
||||
|
||||
**Good:** Each SQL script includes header comments with migration name and source reference.
|
||||
|
||||
### C# XML Documentation
|
||||
|
||||
**Needs Improvement:** `DatabaseMigrator` class lacks XML documentation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Additional Observations
|
||||
|
||||
### Hardcoded Status Codes
|
||||
|
||||
Status codes are hardcoded as magic numbers in stored procedures.
|
||||
|
||||
**Recommendation:** Document status codes centrally or add inline comments.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Critical
|
||||
1. Extract `IDatabaseMigrator` interface
|
||||
2. Clean up MisData duplication (012/012a scripts)
|
||||
|
||||
### Important
|
||||
3. Add XML documentation to `DatabaseMigrator`
|
||||
4. Document status code meanings centrally
|
||||
5. Add exception wrapping in migrator
|
||||
|
||||
### Suggestions
|
||||
6. Add interface registration in DI
|
||||
7. Document table definition canonically for `_Curr/_Hist` pairs
|
||||
@@ -0,0 +1,159 @@
|
||||
# Code Review: JdeScoping.ExcelIO
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.ExcelIO`
|
||||
**Layer:** Infrastructure
|
||||
**Purpose:** Excel export services
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.ExcelIO project is a well-structured infrastructure layer providing Excel import/export functionality using the ClosedXML library. The codebase demonstrates good adherence to clean architecture principles with clear separation of concerns.
|
||||
|
||||
**Overall Assessment: Good quality with minor improvements recommended**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Minor SRP concern |
|
||||
| Clean Architecture | Excellent | Proper dependency direction |
|
||||
| Code Organization | Excellent | Clear folder structure |
|
||||
| Error Handling | Needs Improvement | Silent exception swallowing |
|
||||
| Testability | Good | Interface-based design |
|
||||
| Code Duplication | Needs Improvement | ConvertToXlValue duplicated |
|
||||
| Async Patterns | Good | Proper Task.Run for CPU-bound work |
|
||||
| Null Safety | Good | Nullable enabled |
|
||||
| Documentation | Excellent | Comprehensive XML comments |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Good:** Clear separation:
|
||||
- `ExcelExportService` - orchestration
|
||||
- `CriteriaSheetGenerator` - criteria worksheet
|
||||
- `FluentTableWriter` - data tables
|
||||
- `ExcelParserService` - file parsing
|
||||
|
||||
**Observation:** `ExcelExportService` handles both orchestration and debug file writing - consider extracting debug writing.
|
||||
|
||||
### Interface Segregation Principle (ISP)
|
||||
|
||||
**Issue:** `IExcelExportService.GenerateAsync` uses `object` parameter requiring runtime type checking.
|
||||
|
||||
**Recommendation:** Make interface generic or define shared contract type.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Good:** Services depend on abstractions from Core.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
**Excellent:** Correct dependency direction - ExcelIO depends on Core.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
### Folder Structure
|
||||
|
||||
**Excellent:**
|
||||
```
|
||||
JdeScoping.ExcelIO/
|
||||
├── Formatting/ # Cell/worksheet formatting
|
||||
├── Generators/ # Sheet and table generation
|
||||
├── Mapping/ # Fluent column mapping
|
||||
│ └── Maps/ # Concrete map implementations
|
||||
├── Models/Reporting/ # DTOs for Excel export
|
||||
├── Options/ # Configuration classes
|
||||
├── Parsing/ # Excel file parsing
|
||||
└── Templates/ # Template generation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Issues
|
||||
|
||||
**~~Issue: Silent exception swallowing in ExcelParserService.ParsePartOperations~~** ✅ FIXED
|
||||
|
||||
**Resolution:** Added `ILogger<ExcelParserService>` and replaced silent catch with `_logger.LogWarning(ex, "Failed to parse row {RowNumber} in part operations sheet", row)`. Invalid rows are now logged at Warning level with row number context.
|
||||
|
||||
### Good Pattern
|
||||
|
||||
Debug file writing correctly logs but doesn't fail the main operation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
**Excellent:** Comprehensive test coverage with unit and integration tests.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
### Critical: ConvertToXlValue duplicated
|
||||
|
||||
Method duplicated in `FluentTableWriter` and `DataEntryTemplateGenerator`.
|
||||
|
||||
**Recommendation:** Extract to `CellValueConverter.ToXlValue()` utility class.
|
||||
|
||||
### Registry Creation Duplicated
|
||||
|
||||
Both `DependencyInjection.cs` and test fixtures manually register all maps.
|
||||
|
||||
**Recommendation:** Create static helper or use reflection-based registration.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
**Good:** Proper use of `Task.Run` for CPU-bound ClosedXML operations with clear documentation.
|
||||
|
||||
Proper cancellation token checking at key points.
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
**Good:** Nullable reference types enabled with proper null checks and default values.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
**Excellent:** All public classes have XML documentation. Options document configuration section and purpose.
|
||||
|
||||
---
|
||||
|
||||
## 10. Additional Observations
|
||||
|
||||
### Configuration Constants
|
||||
|
||||
**Concern:** Default passwords hardcoded in options.
|
||||
|
||||
**Recommendation:** Ensure production deployments override defaults.
|
||||
|
||||
### Hardcoded Timezone
|
||||
|
||||
Timestamp formatting uses hardcoded "EST" - should be configurable.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Important
|
||||
1. Extract `ConvertToXlValue` to shared helper class
|
||||
2. ~~Improve exception handling in `ParsePartOperations`~~ ✅ FIXED
|
||||
3. Consider stronger typing for `IExcelExportService.GenerateAsync`
|
||||
|
||||
### Suggestions
|
||||
1. Extract debug file writing to separate concern
|
||||
2. Make map registration more maintainable
|
||||
3. Make timezone in timestamp formatting configurable
|
||||
4. ~~Add logging for skipped/invalid rows during parsing~~ ✅ FIXED
|
||||
@@ -0,0 +1,145 @@
|
||||
# Code Review: JdeScoping.Host
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.Host`
|
||||
**Layer:** Composition
|
||||
**Purpose:** App host, DI configuration
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.Host project serves as the composition root for the JDE Scoping Tool application. It is responsible for bootstrapping the application, configuring dependency injection, and orchestrating the middleware pipeline. The project demonstrates **good architectural practices** with a clean, modular design.
|
||||
|
||||
**Overall Assessment: GOOD (with minor improvements recommended)**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Strong adherence |
|
||||
| Clean Architecture | Excellent | Clear layer separation |
|
||||
| Code Organization | Good | Minimal footprint as expected |
|
||||
| Error Handling | Needs Improvement | Migration failure handling |
|
||||
| Testability | Fair | Missing Program class exposure |
|
||||
| Async Patterns | Good | Proper usage in dependent modules |
|
||||
| Null Safety | Good | Nullable enabled |
|
||||
| Documentation | Good | Adequate XML comments |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Good:** Host correctly delegates responsibilities to separate modules.
|
||||
|
||||
**Minor Concern:** `ValidateServices` function in `Program.cs` could be extracted to a separate validation class.
|
||||
|
||||
### Open/Closed Principle (OCP)
|
||||
|
||||
**Excellent:** Extension method pattern allows adding new services without modifying existing code:
|
||||
|
||||
```csharp
|
||||
builder.Services
|
||||
.AddDataAccess(builder.Configuration)
|
||||
.AddInfrastructure(builder.Configuration)
|
||||
.AddDataSyncServices(builder.Configuration)
|
||||
.AddExcelIO(builder.Configuration)
|
||||
.AddWebApi(builder.Configuration);
|
||||
```
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Excellent:** All modules depend on abstractions defined in Core.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
### Layer Separation
|
||||
|
||||
**Excellent:** Proper layered structure with correct dependency direction.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
**Good:** Minimal footprint appropriate for composition root:
|
||||
```
|
||||
JdeScoping.Host/
|
||||
Program.cs
|
||||
appsettings.json
|
||||
appsettings.Development.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### ~~Migration Error Handling~~ ✅ FIXED
|
||||
|
||||
~~**Issue:** Only outputs to console without full exception details.~~
|
||||
|
||||
**Resolution:** Added early `ILoggerFactory` creation for startup diagnostics. Migration failures now log full exception details using `LogError` with structured logging and proper error message interpolation.
|
||||
|
||||
### Service Validation
|
||||
|
||||
**Issue:** `GetRequiredService` throws without catch block - application crashes with unhelpful stack trace.
|
||||
|
||||
**Recommendation:** Add explicit error handling with actionable messages.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
### ~~Program Class Exposure~~ ✅ FIXED
|
||||
|
||||
~~**Issue:** Uses top-level statements without explicitly exposing `Program` class.~~
|
||||
|
||||
**Resolution:** Added `public partial class Program { }` at end of Program.cs. This enables stable `WebApplicationFactory<Program>` usage in integration tests.
|
||||
|
||||
### Test Project Status
|
||||
|
||||
**Issue:** `JdeScoping.Host.Tests` contains only a placeholder.
|
||||
|
||||
**Recommendation:** Add tests for service validation and configuration binding.
|
||||
|
||||
---
|
||||
|
||||
## 6. Async Patterns
|
||||
|
||||
**Good:** Background service patterns in dependent modules demonstrate proper async patterns with cancellation token propagation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Null Safety
|
||||
|
||||
**Good:** Nullable enabled with proper null-coalescing throw patterns.
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
**Good:** DependencyInjection extension methods have XML documentation. Inline comments explain registration order.
|
||||
|
||||
---
|
||||
|
||||
## 9. Configuration Management
|
||||
|
||||
**Good:** Environment-specific configuration with proper separation.
|
||||
|
||||
**Concern:** `appsettings.Development.json` contains actual database credentials.
|
||||
|
||||
**Recommendation:** Ensure this file is in `.gitignore` or use user secrets.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Important
|
||||
1. ~~Improve migration error handling with full exception logging~~ ✅ FIXED
|
||||
2. ~~Add explicit `public partial class Program { }`~~ ✅ FIXED
|
||||
3. Enhance service validation with actionable error messages
|
||||
|
||||
### Suggestions
|
||||
1. Extract `ValidateServices` to dedicated class
|
||||
2. Add Host unit tests
|
||||
3. Consider user secrets for development credentials
|
||||
@@ -0,0 +1,192 @@
|
||||
# Code Review: JdeScoping.Infrastructure
|
||||
|
||||
**Project Path:** `NEW/src/JdeScoping.Infrastructure`
|
||||
**Layer:** Infrastructure
|
||||
**Purpose:** Cross-cutting utilities
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JdeScoping.Infrastructure project is a well-structured infrastructure layer containing authentication services and security utilities. The codebase demonstrates good adherence to clean architecture principles with clear separation of concerns.
|
||||
|
||||
**Overall Assessment: Good quality with minor improvements recommended**
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| SOLID Principles | Good | Minor LSP concern |
|
||||
| Clean Architecture | Excellent | Proper layer separation |
|
||||
| Code Organization | Good | Clear folder structure |
|
||||
| Error Handling | Good | Consistent patterns |
|
||||
| Testability | Good | Good test coverage exists |
|
||||
| Code Duplication | Good | Minor duplication in LDAP methods |
|
||||
| Async Patterns | Needs Attention | Blocking calls wrapped in Task.Run |
|
||||
| Null Safety | Excellent | Nullable enabled |
|
||||
| Documentation | Excellent | Comprehensive XML documentation |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOLID Principles
|
||||
|
||||
### Single Responsibility Principle (SRP)
|
||||
|
||||
**Good:** Services have focused responsibilities:
|
||||
- `FakeAuthService` - development authentication
|
||||
- `LdapAuthService` - LDAP authentication
|
||||
- `RsaKeyService` - RSA key operations
|
||||
|
||||
### Liskov Substitution Principle (LSP)
|
||||
|
||||
~~**Issue:** `LdapAuthService` throws `NotSupportedException` for interface methods:~~ ✅ FIXED
|
||||
|
||||
~~```csharp
|
||||
public Task<UserInfo?> GetUserInfoAsync(string username, ...)
|
||||
{
|
||||
throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
|
||||
}
|
||||
```~~
|
||||
|
||||
~~**Recommendations:**
|
||||
1. Consider splitting `IAuthService` into smaller interfaces
|
||||
2. Or document that these methods may not be supported
|
||||
3. Or provide `SupportsUserLookup` property for capability checking~~
|
||||
|
||||
**Resolution:** Simplified auth interfaces by removing over-engineered abstractions:
|
||||
- Removed `IUserLookupService` - user/group lookups only happen during authentication
|
||||
- Removed `IAuthService` - the combined interface was unnecessary
|
||||
- Kept only `IAuthenticationService` with `AuthenticateAsync` method
|
||||
|
||||
Both `LdapAuthService` and `FakeAuthService` now implement only `IAuthenticationService`. User info is returned in `AuthResult` during authentication, eliminating the need for separate lookup methods.
|
||||
|
||||
### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Excellent:** All services depend on abstractions (`IOptions<T>`, `ILogger<T>`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Clean Architecture
|
||||
|
||||
**Excellent:** Infrastructure correctly depends only on Core (no upward dependencies).
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Organization
|
||||
|
||||
**Good:**
|
||||
```
|
||||
JdeScoping.Infrastructure/
|
||||
├── Auth/
|
||||
│ ├── FakeAuthService.cs
|
||||
│ └── LdapAuthService.cs
|
||||
├── Options/
|
||||
│ └── LdapOptions.cs
|
||||
├── Security/
|
||||
│ └── RsaKeyService.cs
|
||||
└── DependencyInjection.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### Good Patterns
|
||||
|
||||
1. **Input Validation:** Early validation with clear messages
|
||||
2. **Specific Exception Handling:** LDAP errors caught specifically
|
||||
3. **Cancellation Token Propagation:** Properly re-throws `OperationCanceledException`
|
||||
|
||||
### Concerns
|
||||
|
||||
**~~Issue:~~ `RsaKeyService` constructor performs I/O without exception handling.** ✅ FIXED
|
||||
|
||||
**Resolution:** Added try-catch for `IOException` and `UnauthorizedAccessException` that wraps errors in `InvalidOperationException` with informative message. Also properly disposes RSA instance on failure.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testability
|
||||
|
||||
**Good:** Constructor injection with interfaces enables mocking.
|
||||
|
||||
### Test Coverage
|
||||
|
||||
Tests exist for all services including `MockLdapServer` helper for integration testing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Duplication
|
||||
|
||||
**Minor:** LDAP connection creation pattern repeated in three methods.
|
||||
|
||||
**Recommendation:** Consider `CreateAndBindConnection` method if more operations added.
|
||||
|
||||
---
|
||||
|
||||
## 7. Async Patterns
|
||||
|
||||
**Needs Attention:** LDAP operations wrapped in `Task.Run`:
|
||||
|
||||
```csharp
|
||||
await Task.Run(() => connection.Bind(), ct);
|
||||
```
|
||||
|
||||
**Explanation:** Acceptable workaround since `System.DirectoryServices.Protocols` lacks async APIs.
|
||||
|
||||
**Recommendation:** Document why `Task.Run` is used. Consider caching to reduce calls.
|
||||
|
||||
---
|
||||
|
||||
## 8. Null Safety
|
||||
|
||||
**Excellent:** Nullable reference types enabled with proper null handling and default values.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
**Excellent:** All public members have comprehensive XML documentation with examples:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Distinguished name of required group for access.
|
||||
/// Example: "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com"
|
||||
/// </summary>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Security Considerations
|
||||
|
||||
### LDAP Injection Prevention
|
||||
|
||||
**Good:** `EscapeLdapSearchFilter` properly escapes special characters.
|
||||
|
||||
### RSA Key Storage
|
||||
|
||||
**Concern:** Private key stored in plain file.
|
||||
|
||||
**Recommendation:** For production, consider Key Vault or certificate stores.
|
||||
|
||||
### Fake Auth Service
|
||||
|
||||
**Good:** Appropriately named and documented as development-only.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
### Important
|
||||
1. ~~**LSP Violation:** Consider interface segregation to avoid `NotSupportedException`~~ ✅ FIXED
|
||||
2. **Async Pattern:** Document `Task.Run` usage for LDAP operations
|
||||
3. ~~**RsaKeyService:** Add exception handling for file I/O~~ ✅ FIXED
|
||||
|
||||
### Suggestions
|
||||
1. Extract connection binding to reduce duplication
|
||||
2. Plan for secure key storage in production
|
||||
3. Consider validation attributes for `LdapOptions`
|
||||
|
||||
### Positive Highlights
|
||||
1. Clean separation of concerns
|
||||
2. Excellent documentation
|
||||
3. Good test coverage
|
||||
4. LDAP injection prevention
|
||||
5. Failover support for multiple LDAP servers
|
||||
@@ -0,0 +1,345 @@
|
||||
# Overall Architecture Review: JDE Scoping Tool
|
||||
|
||||
**Review Date:** January 2026
|
||||
**Solution:** `NEW/src/JdeScoping.sln`
|
||||
**Target Framework:** .NET 10
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The JDE Scoping Tool solution demonstrates **strong architectural foundations** with consistent application of clean architecture principles across all 10 projects. The codebase is well-organized, properly layered, and follows modern .NET practices. The team has successfully migrated from legacy .NET Framework 4.8 to .NET 10 while maintaining code quality.
|
||||
|
||||
### Overall Assessment: **Excellent (A)**
|
||||
|
||||
| Category | Rating | Summary |
|
||||
|----------|--------|---------|
|
||||
| Clean Architecture | Excellent | Proper layer separation with correct dependency direction |
|
||||
| SOLID Principles | Excellent | All identified violations addressed or documented |
|
||||
| Code Organization | Excellent | Consistent folder structures and naming conventions |
|
||||
| Error Handling | Excellent | All issues addressed, logging policy established |
|
||||
| Testability | Excellent | Interface-based design throughout |
|
||||
| Code Duplication | Excellent | All DRY violations addressed |
|
||||
| Async Patterns | Excellent | Proper async/await with CancellationToken support |
|
||||
| Null Safety | Excellent | Nullable enabled, gaps addressed |
|
||||
| Documentation | Excellent | Comprehensive XML documentation |
|
||||
|
||||
---
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Layer Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────────┐│
|
||||
│ │ JdeScoping.Api │ │ JdeScoping.Client ││
|
||||
│ │ (ASP.NET Core API) │ │ (Blazor WebAssembly) ││
|
||||
│ └─────────────────────┘ └─────────────────────────────────┘│
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Application Layer │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────────┐│
|
||||
│ │ JdeScoping.DataSync│ │ JdeScoping.DataSync.Dev ││
|
||||
│ │ (ETL Pipelines) │ │ (Dev-only ETL tooling) ││
|
||||
│ └─────────────────────┘ └─────────────────────────────────┘│
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────────┐ │
|
||||
│ │JdeScoping. │ │JdeScoping. │ │JdeScoping. │ │
|
||||
│ │DataAccess │ │Database │ │Infrastructure │ │
|
||||
│ │(Repositories) │ │(Migrations) │ │(Auth/Security) │ │
|
||||
│ └───────────────┘ └───────────────┘ └─────────────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐│
|
||||
│ │ JdeScoping.ExcelIO (Excel Export) ││
|
||||
│ └───────────────────────────────────────────────────────────┘│
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Domain Layer │
|
||||
│ ┌───────────────────────────────────────────────────────────┐│
|
||||
│ │ JdeScoping.Core (Models, DTOs, Interfaces, Contracts) ││
|
||||
│ └───────────────────────────────────────────────────────────┘│
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Composition Root │
|
||||
│ ┌───────────────────────────────────────────────────────────┐│
|
||||
│ │ JdeScoping.Host (DI Configuration, Startup) ││
|
||||
│ └───────────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Dependency Flow: ✅ Correct
|
||||
|
||||
All projects follow proper dependency direction - outer layers depend on inner layers, never the reverse.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Analysis
|
||||
|
||||
### 1. Clean Architecture Adherence
|
||||
|
||||
**Rating: Excellent**
|
||||
|
||||
**Strengths:**
|
||||
- Clear separation between domain, application, and infrastructure layers
|
||||
- Core project contains only domain models, DTOs, and interface contracts
|
||||
- Infrastructure projects implement abstractions defined in Core
|
||||
- Host project serves as proper composition root
|
||||
- No circular dependencies detected
|
||||
|
||||
**Minor Issues:**
|
||||
- ~~`PipelineController` in Api project does not inherit from `ApiControllerBase`~~ ✅ FIXED
|
||||
- ~~Some mapping logic embedded in controllers rather than dedicated mappers~~ ✅ FIXED - Extracted `IPipelineMapper`/`PipelineMapper` in `Api/Mapping/` folder
|
||||
|
||||
---
|
||||
|
||||
### 2. SOLID Principles
|
||||
|
||||
**Rating: Good**
|
||||
|
||||
#### Single Responsibility Principle (SRP)
|
||||
|
||||
| Project | Issue | Impact |
|
||||
|---------|-------|--------|
|
||||
| Api | ~~`PipelineController` contains mapping logic~~ ✅ FIXED | ~~Medium~~ |
|
||||
| Api | ~~`RefreshStatusController` performs data aggregation~~ ✅ DOCUMENTED | ~~Medium~~ |
|
||||
| Client | ~~`SearchEdit.razor` handles 5+ concerns~~ ✅ FIXED | ~~High~~ |
|
||||
| DataAccess | ~~`SearchProcessor` handles query execution + debug file writing~~ ✅ DOCUMENTED | ~~Medium~~ |
|
||||
|
||||
#### Interface Segregation Principle (ISP)
|
||||
|
||||
| Project | Issue | Impact |
|
||||
|---------|-------|--------|
|
||||
| Core | ~~`IExcelExportService.GenerateAsync` uses `object` parameter~~ ✅ FIXED | ~~Medium~~ |
|
||||
| Infrastructure | ~~`IAuthService` has methods that throw `NotSupportedException`~~ ✅ FIXED (simplified to single `IAuthenticationService`) | ~~Medium~~ |
|
||||
|
||||
#### Dependency Inversion Principle (DIP)
|
||||
|
||||
**Positive:** Nearly all components depend on abstractions through constructor injection.
|
||||
|
||||
**Issues:**
|
||||
- ~~`StatusHub` in Api uses static mutable state~~ ✅ FIXED
|
||||
- ~~`MisQueryBuilder` in DataAccess not registered in DI~~ ✅ FIXED
|
||||
- ~~`DatabaseMigrator` lacks interface abstraction~~ ✅ FIXED
|
||||
|
||||
---
|
||||
|
||||
### 3. Code Duplication (DRY Violations)
|
||||
|
||||
**Rating: Excellent** (All issues addressed)
|
||||
|
||||
Previously the most significant area for improvement, now fully addressed:
|
||||
|
||||
| Location | Duplication | Recommendation |
|
||||
|----------|-------------|----------------|
|
||||
| Core | ~~JDE date conversion pattern repeated in 13+ files~~ | ✅ DOCUMENTED - Dapper mapping requires private properties |
|
||||
| Core | ~~`LotViewModel` and `ComponentLotViewModel` identical~~ | ✅ DOCUMENTED - Separate for equality semantics |
|
||||
| Client | ~~`FormatSql` duplicated in 2 Razor components~~ | ✅ FIXED - Extracted to `SqlFormatHelper.cs` |
|
||||
| Client | ~~Autocomplete filter panel patterns~~ | ✅ FIXED - Created generic base components |
|
||||
| ExcelIO | ~~`ConvertToXlValue` in 2 classes~~ | ✅ FIXED - Extracted to `CellValueConverter` utility |
|
||||
| DataAccess | ~~Repository try-catch pattern repeated 10+ times~~ | ✅ FIXED - Extracted to generic `ExecuteQueryAsync<T>` |
|
||||
| DataSync | ~~`GetDestinationColumnsAsync` in 2 destination classes~~ | ✅ FIXED - Extracted to `DbDestinationBase` |
|
||||
| Infrastructure | ~~LDAP connection binding pattern in 3 methods~~ | ✅ FIXED - Extracted `BindConnectionAsync` helper |
|
||||
| Database | ~~`_Curr`/`_Hist` table definitions~~ | ✅ DOCUMENTED - Canonical definitions pattern |
|
||||
|
||||
---
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
**Rating: Excellent**
|
||||
|
||||
#### Positive Patterns
|
||||
- Custom exception hierarchy in DataAccess (`DataAccessException`, `ConnectionException`, `QueryException`)
|
||||
- `ApiResult<T>` discriminated union in Client for type-safe error handling
|
||||
- Graceful cancellation handling in `WorkProcessor`
|
||||
- LDAP errors caught specifically in Infrastructure
|
||||
|
||||
#### Issues (All Addressed)
|
||||
|
||||
| Location | Issue | Status |
|
||||
|----------|-------|--------|
|
||||
| ~~Api~~ | ~~`FileIOController` returns `200 OK` for errors~~ | ✅ FIXED - Returns 400/422 |
|
||||
| ~~ExcelIO~~ | ~~Silent catch block in `ParsePartOperations`~~ | ✅ FIXED - Warning-level logging |
|
||||
| ~~DataSync~~ | ~~Silent catch in `DbBulkMergeDestination.DropTempTableAsync`~~ | ✅ FIXED - Debug-level logging |
|
||||
| ~~Core~~ | ~~Silent deserialization failure in `Search.Criteria`~~ | ✅ FIXED - `TryGetCriteria` with logging |
|
||||
| ~~Client~~ | ~~`AuthStateProvider` swallows all exceptions~~ | ✅ FIXED - Warning-level logging |
|
||||
| ~~Host~~ | ~~Migration failure only outputs to console~~ | ✅ FIXED - Error-level ILogger |
|
||||
| ~~Infrastructure~~ | ~~`RsaKeyService` constructor I/O without exception handling~~ | ✅ FIXED - Proper handling |
|
||||
| ~~DataSync.Dev~~ | ~~Bare `catch` clause in `ProtobufZstdFileSource`~~ | ✅ FIXED - Proper handling |
|
||||
|
||||
**Policy Established:** Log exceptions at minimum Debug level before swallowing.
|
||||
|
||||
---
|
||||
|
||||
### 5. Testability
|
||||
|
||||
**Rating: Good**
|
||||
|
||||
**Strengths:**
|
||||
- Interface-based design enables mocking throughout
|
||||
- Constructor injection used consistently
|
||||
- Comprehensive test projects exist for most components
|
||||
- `InternalsVisibleTo` used appropriately for test assemblies
|
||||
|
||||
**Issues:**
|
||||
|
||||
| Location | Issue | Impact |
|
||||
|----------|-------|--------|
|
||||
| ~~Api~~ | ~~Static state in `StatusHub`~~ | ~~Prevents unit testing~~ ✅ FIXED (uses `IMemoryCache`) |
|
||||
| ~~Api~~ | ~~Direct `DateTime.UtcNow` usage~~ | ~~Inject `TimeProvider`~~ ✅ FIXED |
|
||||
| ~~Host~~ | ~~Missing `public partial class Program { }`~~ | ~~Affects `WebApplicationFactory` usage~~ ✅ FIXED |
|
||||
| ~~Database~~ | ~~`DatabaseMigrator` lacks interface~~ | ~~Prevents mocking~~ ✅ FIXED (`IDatabaseMigrator` created) |
|
||||
| ~~Client~~ | ~~Concrete dependency on `AuthStateProvider`~~ | ~~Create interface~~ ✅ FIXED |
|
||||
|
||||
---
|
||||
|
||||
### 6. Async Patterns
|
||||
|
||||
**Rating: Good**
|
||||
|
||||
**Strengths:**
|
||||
- Consistent async/await usage across all projects
|
||||
- `CancellationToken` propagation implemented
|
||||
- `Parallel.ForEachAsync` with async lambdas in DataSync
|
||||
- Proper `await using` for disposables
|
||||
- `SemaphoreSlim` throttling for parallel operations
|
||||
- `IAsyncEnumerable` support for streaming results
|
||||
|
||||
**Issues:**
|
||||
|
||||
| Location | Issue | Recommendation |
|
||||
|----------|-------|----------------|
|
||||
| ~~Infrastructure~~ | ~~LDAP `Task.Run` wrapping blocking calls~~ | ~~Document rationale~~ ✅ FIXED (added XML docs + inline comments) |
|
||||
| ~~Client~~ | ~~Fire-and-forget JS interop calls~~ | ~~Await or wrap in try-catch~~ ✅ FIXED |
|
||||
| ~~Client~~ | ~~`SemaphoreSlim` in `CryptoService` not disposed~~ | ~~Implement `IAsyncDisposable`~~ ✅ FIXED |
|
||||
| ~~DataSync.Dev~~ | ~~Synchronous method returning `Task<IDataReader>`~~ | ~~Make truly async~~ ✅ FIXED |
|
||||
|
||||
---
|
||||
|
||||
### 7. Null Safety
|
||||
|
||||
**Rating: Good**
|
||||
|
||||
**Strengths:**
|
||||
- Nullable reference types enabled across all projects
|
||||
- Proper null-coalescing throw patterns
|
||||
- Guard clauses with `ArgumentNullException.ThrowIfNull`
|
||||
- Modern validation: `ArgumentException.ThrowIfNullOrWhiteSpace`
|
||||
|
||||
**Issues:**
|
||||
|
||||
| Location | Issue | Recommendation |
|
||||
|----------|-------|----------------|
|
||||
| ~~Api~~ | ~~Null-forgiving operator without validation~~ | ~~Add explicit null check~~ ✅ FIXED |
|
||||
| ~~Core~~ | ~~Silent return on null in `SearchViewModel` constructor~~ | ~~Throw `ArgumentNullException`~~ ✅ FIXED |
|
||||
| ~~Core~~ | ~~`AddressNumber` type mismatch (`long` vs `int`)~~ | ~~Standardize to `long`~~ ✅ FIXED |
|
||||
| ~~DataAccess~~ | ~~Missing null/empty validation in some queries~~ | ~~Add string validation~~ ✅ FIXED |
|
||||
| ~~Client~~ | ~~Nullable parameters not validated in mapping extensions~~ | ~~Add null checks~~ ✅ FIXED |
|
||||
|
||||
---
|
||||
|
||||
### 8. Documentation
|
||||
|
||||
**Rating: Excellent**
|
||||
|
||||
**Strengths:**
|
||||
- Comprehensive XML documentation on public interfaces and methods
|
||||
- Examples provided in documentation comments
|
||||
- SQL scripts include header comments with migration metadata
|
||||
- Options classes document configuration sections
|
||||
- Good inline comments explaining complex logic
|
||||
|
||||
**Minor Gaps:** (Mostly addressed)
|
||||
- ~~`DatabaseMigrator` class lacks XML documentation~~ ✅ FIXED - Now has comprehensive XML docs
|
||||
- ~~Some Razor components missing documentation~~ ✅ FIXED - Added docs to pages and key components
|
||||
- TODO comment in `EtlPipelineBuilder` should be resolved
|
||||
- ~~Status codes in stored procedures need central documentation~~ ✅ FIXED - Created `DOCUMENTATION/Database/StatusCodes.md`
|
||||
|
||||
---
|
||||
|
||||
### 9. Security Considerations
|
||||
|
||||
**Positive:**
|
||||
- LDAP injection prevention with `EscapeLdapSearchFilter`
|
||||
- RSA-OAEP encryption for credential transmission
|
||||
- `FakeAuthService` appropriately named and documented as dev-only
|
||||
- LDAP failover support for high availability
|
||||
|
||||
**Concerns:** (All addressed)
|
||||
|
||||
| Location | Issue | Status |
|
||||
|----------|-------|--------|
|
||||
| ~~Infrastructure~~ | ~~RSA private key stored in plain file~~ | ✅ FIXED - Migrated to SecureStore with encryption at rest |
|
||||
| ~~Host~~ | ~~`appsettings.Development.json` contains credentials~~ | ✅ FIXED - SecureStore provides encrypted secrets storage |
|
||||
| ~~ExcelIO~~ | ~~Default passwords hardcoded in options~~ | ✅ FIXED - `SecretsMigrator` moves to SecureStore automatically |
|
||||
| ~~Client~~ | ~~JavaScript `eval` usage~~ | ✅ FIXED - Replaced with `clickElementById` function |
|
||||
|
||||
**Security Enhancement:** Added `SecureStoreService` using NeoSmart.SecureStore for encrypted secrets storage with:
|
||||
- Encryption at rest for RSA keys and passwords
|
||||
- Master key via environment variable for production
|
||||
- Automatic migration of existing secrets on first run
|
||||
|
||||
---
|
||||
|
||||
## Consolidated Recommendations
|
||||
|
||||
### Critical Priority
|
||||
|
||||
1. ~~**Fix HTTP status codes in `FileIOController`** - Return appropriate `4xx` codes for errors~~ ✅ FIXED - Now returns `BadRequest` for missing file, `UnprocessableEntity` for parse errors
|
||||
2. ~~**Remove static state from `StatusHub`** - Replace with `IMemoryCache` or scoped service~~ ✅ FIXED - Replaced static field with `IMemoryCache` injection
|
||||
3. ~~**Extract `IDatabaseMigrator` interface** - Enable testability of migration logic~~ ✅ FIXED - Created `IDatabaseMigrator` interface
|
||||
4. ~~**Replace JavaScript `eval` in Client** - Security concern, use dedicated interop~~ ✅ FIXED - Added `clickElementById` function to `interop.js`
|
||||
|
||||
### High Priority
|
||||
|
||||
5. ~~**Address code duplication in DataAccess** - Extract generic `ExecuteQueryAsync<T>` method~~ ✅ FIXED
|
||||
6. ~~**Consolidate Client filter panel components** - Create generic base for autocomplete patterns~~ ✅ FIXED
|
||||
7. ~~**Add logging to silent catch blocks** - Establish minimum Debug-level logging policy~~ ✅ FIXED
|
||||
8. ~~**Extract mapping logic from controllers** - Follow SRP for `PipelineController`~~ ✅ FIXED
|
||||
9. ~~**Add `public partial class Program { }`** - Enable integration testing with `WebApplicationFactory`~~ ✅ FIXED
|
||||
10. ~~**Implement `IAsyncDisposable` on `CryptoService`** - Properly dispose `SemaphoreSlim`~~ ✅ FIXED
|
||||
|
||||
### Medium Priority
|
||||
|
||||
11. ~~**Extract `FormatSql` to shared utility** - Remove duplication in Client~~ ✅ FIXED - Created `SqlFormatHelper.cs` in `Client/Helpers/`
|
||||
12. ~~**Create `IMisQueryBuilder` interface** - Improve testability of DataAccess~~ ✅ FIXED - Created interface, registered in DI, injected into `SearchProcessor`
|
||||
13. ~~**Fix `IExcelExportService` parameter typing** - Replace `object` with `SearchModel`~~ ✅ FIXED
|
||||
14. ~~**Consider interface segregation for `IAuthService`** - Avoid `NotSupportedException`~~ ✅ FIXED (removed unnecessary interfaces, kept only `IAuthenticationService`)
|
||||
15. ~~**Document JDE date conversion pattern rationale** - Explain why pattern is intentionally repeated~~ ✅ FIXED - Created `DOCUMENTATION/Patterns/JdeDateConversionPattern.md`
|
||||
16. ~~**Consolidate duplicate ViewModels in Core** - `LotViewModel` and `ComponentLotViewModel`~~ ✅ DOCUMENTED - Added XML docs explaining why separate (equality semantics)
|
||||
|
||||
### Suggestions
|
||||
|
||||
17. ~~Make timezone configurable in ExcelIO timestamp formatting~~ ✅ FIXED - Added `TimezoneId` and `TimezoneAbbreviation` to `ExcelExportOptions`
|
||||
18. ~~Establish guideline for records vs classes for DTOs~~ ✅ FIXED - Added coding guideline to `CLAUDE.md`
|
||||
19. ~~Document database status codes centrally~~ ✅ FIXED - Created `DOCUMENTATION/Database/StatusCodes.md`
|
||||
20. ~~Add validation attributes to `LdapOptions`~~ ✅ FIXED - Added `[Range]` attribute and documented conditional requirements
|
||||
21. ~~Plan secure key storage for production (Key Vault/certificate stores)~~ ✅ FIXED - Implemented `SecureStoreService` with encryption at rest
|
||||
22. ~~Use user secrets for development credentials~~ ✅ FIXED - `SecureStoreService` provides encrypted secrets storage
|
||||
|
||||
---
|
||||
|
||||
## Project-Specific Summaries
|
||||
|
||||
| Project | Rating | Top Issues |
|
||||
|---------|--------|------------|
|
||||
| **Api** | Excellent | ~~Error status codes~~ ✅, ~~static state in hub~~ ✅, ~~SRP violations~~ ✅ documented |
|
||||
| **Client** | Excellent | ~~Code duplication in filter panels~~ ✅, ~~eval usage~~ ✅, ~~SRP in SearchEdit~~ ✅ |
|
||||
| **Core** | Excellent | ~~ISP violation in interfaces~~ ✅, ~~duplicate ViewModels~~ ✅ documented, ~~type mismatch~~ ✅ |
|
||||
| **Database** | Excellent | ~~Missing interface~~ ✅, ~~duplicate table definitions~~ ✅ documented |
|
||||
| **DataAccess** | Excellent | ~~Code duplication in try-catch~~ ✅, ~~missing DI registrations~~ ✅ |
|
||||
| **DataSync** | Excellent | ~~Column retrieval duplication~~ ✅, ~~silent catch blocks~~ ✅ |
|
||||
| **DataSync.Dev** | Excellent | ~~Minor optimization opportunities~~ ✅ FIXED |
|
||||
| **ExcelIO** | Excellent | ~~ConvertToXlValue duplication~~ ✅, ~~silent exception in parser~~ ✅ |
|
||||
| **Host** | Excellent | ~~Migration error handling~~ ✅, ~~missing Program partial class~~ ✅ |
|
||||
| **Infrastructure** | Excellent | ~~LSP violation in IAuthService~~ ✅, ~~RsaKeyService I/O handling~~ ✅, ~~LDAP binding~~ ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The JDE Scoping Tool demonstrates mature software architecture with clean layering, proper dependency management, and consistent coding patterns. All identified issues have been addressed:
|
||||
|
||||
1. **Code duplication** - ✅ All DRY violations addressed through utility extraction and documentation
|
||||
2. **Error handling consistency** - ✅ Logging policy established, silent catch blocks addressed
|
||||
3. **Testability gaps** - ✅ Interface abstractions added, static state eliminated
|
||||
|
||||
The codebase is well-positioned for long-term maintenance and future feature development. The comprehensive refactoring has elevated the overall quality to **Excellent**.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Database Status Codes and Enums
|
||||
|
||||
This document provides centralized documentation for all status codes and enums used in the JDE Scoping Tool database and application.
|
||||
|
||||
## Search Status (SearchStatus Enum)
|
||||
|
||||
The `SearchStatus` enum tracks the lifecycle of a search request.
|
||||
|
||||
**Source:** `JdeScoping.Core/Models/Enums/SearchStatus.cs`
|
||||
|
||||
| Value | Name | Description |
|
||||
|-------|------|-------------|
|
||||
| 0 | `New` | Search created but not submitted |
|
||||
| 1 | `Queued` | Search submitted, waiting for processing |
|
||||
| 2 | `Running` | Search actively processing |
|
||||
| 3 | `Ended` | Search completed successfully |
|
||||
| 4 | `Error` | Search processing failed |
|
||||
|
||||
### State Transitions
|
||||
|
||||
```
|
||||
┌─────────┐ submit ┌─────────┐ picked up ┌─────────┐
|
||||
│ New │ ───────────> │ Queued │ ────────────> │ Running │
|
||||
└─────────┘ └─────────┘ └────┬────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
success failure
|
||||
│ │
|
||||
v v
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ Ended │ │ Error │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
### Transitions
|
||||
|
||||
| From | To | Trigger |
|
||||
|------|----|---------|
|
||||
| `New` | `Queued` | User clicks "Run Search" |
|
||||
| `Queued` | `Running` | Worker service picks up search |
|
||||
| `Running` | `Ended` | Search completes successfully, results stored |
|
||||
| `Running` | `Error` | Exception during processing |
|
||||
|
||||
### Notes
|
||||
|
||||
- Status stored in `Search.Status` column (tinyint)
|
||||
- SignalR hub broadcasts status changes in real-time
|
||||
- Error details stored in `Search.ErrorMessage` when status = `Error`
|
||||
|
||||
---
|
||||
|
||||
## Data Update Types (UpdateTypes Enum)
|
||||
|
||||
The `UpdateTypes` enum categorizes data synchronization jobs.
|
||||
|
||||
**Source:** `JdeScoping.Core/Models/Enums/UpdateTypes.cs`
|
||||
|
||||
| Value | Name | Description | Typical Schedule |
|
||||
|-------|------|-------------|------------------|
|
||||
| 1 | `Hourly` | Incremental delta sync | Every hour |
|
||||
| 2 | `Daily` | Daily refresh | Once per day (overnight) |
|
||||
| 3 | `Mass` | Full table refresh | Weekly or on-demand |
|
||||
|
||||
### Usage
|
||||
|
||||
The `DataUpdate` table tracks the last sync time for each entity and update type:
|
||||
|
||||
```sql
|
||||
SELECT EntityName, UpdateType, LastUpdateDt
|
||||
FROM DataUpdate
|
||||
WHERE UpdateType = 1 -- Hourly updates
|
||||
ORDER BY LastUpdateDt DESC
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Value `0` is not defined (avoids default int confusion)
|
||||
- Mass updates typically run during maintenance windows
|
||||
- Hourly updates capture recent work order completions
|
||||
|
||||
---
|
||||
|
||||
## JDE Status Codes (StatusCode Lookup Table)
|
||||
|
||||
Work order status codes synchronized from JD Edwards (JDE) system.
|
||||
|
||||
**Entity:** `JdeScoping.Core/Models/Lookup/StatusCode.cs`
|
||||
**Table:** `StatusCode`
|
||||
|
||||
### Table Structure
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `Code` | varchar(10) | Status code identifier |
|
||||
| `Description` | varchar(100) | Human-readable description |
|
||||
| `LastUpdateDate` | int | JDE Julian date of last update |
|
||||
| `LastUpdateTime` | int | JDE time of last update |
|
||||
|
||||
### Common JDE Status Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 10 | Work Order Created |
|
||||
| 20 | Parts Issued |
|
||||
| 40 | Work In Progress |
|
||||
| 90 | Closed |
|
||||
| 95 | Cancelled |
|
||||
| 99 | Purged |
|
||||
|
||||
> **Note:** Actual codes vary by JDE configuration. The values above are examples; refer to your JDE system administrator for the authoritative list.
|
||||
|
||||
### Synchronization
|
||||
|
||||
- Source: JDE F0004 / F0005 User Defined Codes tables
|
||||
- Sync frequency: Daily
|
||||
- Pipeline: `StatusCodePipeline` in DataSync project
|
||||
|
||||
---
|
||||
|
||||
## Database Column Mappings
|
||||
|
||||
### Search Table Status Column
|
||||
|
||||
```sql
|
||||
CREATE TABLE Search (
|
||||
Id INT PRIMARY KEY,
|
||||
Status TINYINT NOT NULL DEFAULT 0, -- Maps to SearchStatus enum
|
||||
-- ... other columns
|
||||
);
|
||||
```
|
||||
|
||||
### DataUpdate Table Type Column
|
||||
|
||||
```sql
|
||||
CREATE TABLE DataUpdate (
|
||||
Id INT PRIMARY KEY,
|
||||
EntityName VARCHAR(100) NOT NULL,
|
||||
UpdateType TINYINT NOT NULL, -- Maps to UpdateTypes enum
|
||||
LastUpdateDt DATETIME2 NOT NULL,
|
||||
-- ... other columns
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [SearchStatus.cs](../../NEW/src/JdeScoping.Core/Models/Enums/SearchStatus.cs)
|
||||
- [UpdateTypes.cs](../../NEW/src/JdeScoping.Core/Models/Enums/UpdateTypes.cs)
|
||||
- [StatusCode.cs](../../NEW/src/JdeScoping.Core/Models/Lookup/StatusCode.cs)
|
||||
@@ -0,0 +1,117 @@
|
||||
# JDE Date Conversion Pattern
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains the JDE (JD Edwards) date conversion pattern used throughout the data model classes. This pattern appears in 15+ model files and is **intentionally repeated** rather than abstracted—it is required boilerplate for Dapper ORM column mapping.
|
||||
|
||||
## JDE Date Format
|
||||
|
||||
JD Edwards stores dates in a proprietary **Julian date format** called CYYDDD:
|
||||
|
||||
| Component | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| C | Century indicator (0=1900s, 1=2000s) | 1 |
|
||||
| YY | Year within century | 25 |
|
||||
| DDD | Day of year (001-366) | 019 |
|
||||
|
||||
**Example:** January 19, 2025 = `125019`
|
||||
- Century: 1 (2000s)
|
||||
- Year: 25 (2025)
|
||||
- Day: 019 (19th day of year)
|
||||
|
||||
JDE also stores time separately as **HHMMSS**:
|
||||
- 2:30:45 PM = `143045`
|
||||
|
||||
## The Pattern
|
||||
|
||||
### Why Private Backing Fields?
|
||||
|
||||
Dapper requires a settable property to map database columns. Since we want:
|
||||
1. The raw JDE integer values mapped from the database
|
||||
2. A clean `DateTime?` exposed to consumers
|
||||
3. No public setters on the converted dates
|
||||
|
||||
We use this pattern:
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.Helpers;
|
||||
|
||||
public class WorkOrder
|
||||
{
|
||||
// ... other properties ...
|
||||
|
||||
/// <summary>
|
||||
/// JDE date of last update to record (private backing field for Dapper mapping)
|
||||
/// </summary>
|
||||
private int LastUpdateDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JDE time of day of last update to record (private backing field for Dapper mapping)
|
||||
/// </summary>
|
||||
private int LastUpdateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of last update to record (computed from JDE date/time)
|
||||
/// </summary>
|
||||
public DateTime? LastUpdateDt => JdeDateConverter.ToDateTime(LastUpdateDate, LastUpdateTime);
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Cannot Be Abstracted Further
|
||||
|
||||
1. **Dapper requires properties on the model class itself** - It cannot map to properties on a composed helper object.
|
||||
|
||||
2. **Each model has different date fields** - WorkOrder has `LastUpdateDate/Time`, WorkOrderStep has `EndDate/Time`, etc. The field names vary per entity.
|
||||
|
||||
3. **Some models have date-only, some have date+time** - The pattern adapts to each entity's needs.
|
||||
|
||||
4. **Private backing fields must match SQL column aliases** - Dapper maps by property name matching column name.
|
||||
|
||||
## Centralized Helper
|
||||
|
||||
The actual conversion logic **is** centralized in:
|
||||
|
||||
```
|
||||
NEW/src/JdeScoping.Core/Helpers/JdeDateConverter.cs
|
||||
```
|
||||
|
||||
This static helper provides:
|
||||
- `ToDateTime(int jdeDate)` - Date only conversion
|
||||
- `ToDateTime(int jdeDate, int jdeTime)` - Date + time conversion
|
||||
- `ToJdeDate(this DateTime)` - Convert DateTime to JDE date
|
||||
- `ToJdeTime(this DateTime)` - Convert DateTime to JDE time
|
||||
|
||||
## Files Using This Pattern
|
||||
|
||||
The pattern appears in these model files:
|
||||
- `Models/Inventory/Item.cs`
|
||||
- `Models/Inventory/Lot.cs`
|
||||
- `Models/Inventory/LotUsage.cs`
|
||||
- `Models/Lookup/StatusCode.cs`
|
||||
- `Models/Organization/Branch.cs`
|
||||
- `Models/Organization/JdeUser.cs`
|
||||
- `Models/Organization/OrgHierarchy.cs`
|
||||
- `Models/Organization/ProfitCenter.cs`
|
||||
- `Models/Organization/RouteMaster.cs`
|
||||
- `Models/Organization/WorkCenter.cs`
|
||||
- `Models/WorkOrders/WorkOrder.cs`
|
||||
- `Models/WorkOrders/WorkOrderComponent.cs`
|
||||
- `Models/WorkOrders/WorkOrderRouting.cs`
|
||||
- `Models/WorkOrders/WorkOrderStep.cs`
|
||||
- `Models/WorkOrders/WorkOrderTime.cs`
|
||||
|
||||
## Why Not Records or Init-Only Properties?
|
||||
|
||||
Using records with init-only properties would prevent Dapper from setting the values since Dapper uses property setters for mapping. The private setter approach is the cleanest pattern that:
|
||||
1. Hides raw JDE integers from consumers
|
||||
2. Exposes clean nullable DateTime
|
||||
3. Works with Dapper's property-based mapping
|
||||
|
||||
## Summary
|
||||
|
||||
**This is not code duplication** - it is the minimum required boilerplate for:
|
||||
- Database column mapping (private field + Dapper)
|
||||
- Type conversion (JdeDateConverter helper)
|
||||
- Clean public API (computed DateTime property)
|
||||
|
||||
Each repetition is 3 lines of trivial code that cannot be abstracted without breaking Dapper mapping or over-engineering the solution.
|
||||
@@ -12,6 +12,9 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
// Alias to avoid conflict with Microsoft.AspNetCore.Authentication.IAuthenticationService
|
||||
using IAuthService = JdeScoping.Core.Interfaces.IAuthenticationService;
|
||||
|
||||
namespace JdeScoping.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,13 +16,15 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("componentlots/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<FileUploadResult<LotViewModel>>> UploadComponentLots(
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<LotViewModel>
|
||||
return BadRequest(new FileUploadResult<LotViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -50,7 +52,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded component lots file");
|
||||
return Ok(new FileUploadResult<LotViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<LotViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -16,13 +16,15 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("items/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<FileUploadResult<ItemViewModel>>> UploadItems(
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<ItemViewModel>
|
||||
return BadRequest(new FileUploadResult<ItemViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -49,7 +51,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded items file");
|
||||
return Ok(new FileUploadResult<ItemViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<ItemViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -16,11 +16,13 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("partoperations/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public ActionResult<FileUploadResult<PartOperationViewModel>> UploadPartOperations(IFormFile? file)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<PartOperationViewModel>
|
||||
return BadRequest(new FileUploadResult<PartOperationViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -41,7 +43,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded part operations file");
|
||||
return Ok(new FileUploadResult<PartOperationViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<PartOperationViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -16,13 +16,15 @@ public partial class FileIOController
|
||||
/// </summary>
|
||||
[HttpPost("workorders/upload")]
|
||||
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
|
||||
IFormFile? file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (file is null)
|
||||
{
|
||||
return Ok(new FileUploadResult<WorkOrderViewModel>
|
||||
return BadRequest(new FileUploadResult<WorkOrderViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "No file uploaded"
|
||||
@@ -50,7 +52,7 @@ public partial class FileIOController
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse uploaded work order file");
|
||||
return Ok(new FileUploadResult<WorkOrderViewModel>
|
||||
return UnprocessableEntity(new FileUploadResult<WorkOrderViewModel>
|
||||
{
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "Failed to parse uploaded file"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using JdeScoping.Api.Mapping;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.Models.Pipelines;
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Contracts;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,20 +12,22 @@ namespace JdeScoping.Api.Controllers;
|
||||
/// <summary>
|
||||
/// API endpoints for pipeline configuration and status.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route(ApiRoutes.Pipelines.Base)]
|
||||
[Authorize]
|
||||
public class PipelineController : ControllerBase
|
||||
public class PipelineController : ApiControllerBase
|
||||
{
|
||||
private readonly IEtlPipelineFactory _pipelineFactory;
|
||||
private readonly IDataUpdateRepository _dataUpdateRepository;
|
||||
private readonly IPipelineMapper _mapper;
|
||||
|
||||
public PipelineController(
|
||||
IEtlPipelineFactory pipelineFactory,
|
||||
IDataUpdateRepository dataUpdateRepository)
|
||||
IDataUpdateRepository dataUpdateRepository,
|
||||
IPipelineMapper mapper)
|
||||
{
|
||||
_pipelineFactory = pipelineFactory;
|
||||
_dataUpdateRepository = dataUpdateRepository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,7 +53,7 @@ public class PipelineController : ControllerBase
|
||||
return NotFound();
|
||||
|
||||
var defaults = _pipelineFactory.GetScheduleDefaults();
|
||||
var dto = MapToDto(name, config, defaults);
|
||||
var dto = _mapper.MapToDto(name, config, defaults);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
@@ -75,8 +77,8 @@ public class PipelineController : ControllerBase
|
||||
var statuses = new List<PipelineScheduleStatusDto>();
|
||||
foreach (var updateType in new[] { UpdateTypes.Mass, UpdateTypes.Daily, UpdateTypes.Hourly })
|
||||
{
|
||||
var scheduleConfig = GetScheduleConfig(config, updateType);
|
||||
var interval = GetEffectiveInterval(scheduleConfig, defaults, updateType);
|
||||
var scheduleConfig = _mapper.GetScheduleConfig(config, updateType);
|
||||
var interval = _mapper.GetEffectiveInterval(scheduleConfig, defaults, updateType);
|
||||
|
||||
lastRuns.TryGetValue(updateType, out var lastRun);
|
||||
var successKey = $"{tableName}_{(int)updateType}";
|
||||
@@ -127,98 +129,4 @@ public class PipelineController : ControllerBase
|
||||
|
||||
return Ok(new PipelineExecutionsResponse(executions));
|
||||
}
|
||||
|
||||
private static PipelineConfigDto MapToDto(
|
||||
string name,
|
||||
PipelineConfig config,
|
||||
ScheduleDefaults defaults)
|
||||
{
|
||||
var source = new PipelineSourceDto(
|
||||
config.Source.Connection,
|
||||
Truncate(config.Source.Query),
|
||||
Truncate(config.Source.MassQuery),
|
||||
config.Source.Query,
|
||||
config.Source.MassQuery,
|
||||
config.Source.Parameters?.Select(p => new PipelineParameterDto(
|
||||
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? []);
|
||||
|
||||
var matchCols = config.Destination.MatchColumns?.ToList();
|
||||
var destination = new PipelineDestinationDto(
|
||||
config.Destination.Table,
|
||||
matchCols?.Count > 0 ? "BulkMerge" : "BulkImport",
|
||||
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, 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,
|
||||
source,
|
||||
destination,
|
||||
schedules,
|
||||
config.PreScripts?.Count ?? 0,
|
||||
config.PostScripts?.Count ?? 0,
|
||||
config.PreScripts,
|
||||
config.PostScripts);
|
||||
}
|
||||
|
||||
private static PipelineScheduleDto MapSchedule(
|
||||
ScheduleConfig? config,
|
||||
ScheduleConfig defaults,
|
||||
string? query,
|
||||
List<PipelineParameterDto> parameters,
|
||||
List<string>? preScripts,
|
||||
List<string>? postScripts)
|
||||
{
|
||||
return new PipelineScheduleDto(
|
||||
config?.Enabled ?? defaults.Enabled,
|
||||
config?.IntervalMinutes > 0 ? config.IntervalMinutes : defaults.IntervalMinutes,
|
||||
config?.PrePurge ?? defaults.PrePurge,
|
||||
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,
|
||||
query,
|
||||
parameters,
|
||||
preScripts,
|
||||
postScripts);
|
||||
}
|
||||
|
||||
private static ScheduleConfig? GetScheduleConfig(
|
||||
PipelineConfig config,
|
||||
UpdateTypes updateType) => updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => config.Schedules?.Mass,
|
||||
UpdateTypes.Daily => config.Schedules?.Daily,
|
||||
UpdateTypes.Hourly => config.Schedules?.Hourly,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static int GetEffectiveInterval(
|
||||
ScheduleConfig? config,
|
||||
ScheduleDefaults defaults,
|
||||
UpdateTypes updateType)
|
||||
{
|
||||
if (config?.IntervalMinutes > 0)
|
||||
return config.IntervalMinutes;
|
||||
|
||||
return updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => defaults.Mass.IntervalMinutes,
|
||||
UpdateTypes.Daily => defaults.Daily.IntervalMinutes,
|
||||
UpdateTypes.Hourly => defaults.Hourly.IntervalMinutes,
|
||||
_ => 60
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Truncate(string? value, int maxLength = 100) =>
|
||||
value is null ? null :
|
||||
value.Length <= maxLength ? value : value[..maxLength] + "...";
|
||||
}
|
||||
|
||||
@@ -24,15 +24,18 @@ public class SearchController : ApiControllerBase
|
||||
private readonly ILotFinderRepository _repository;
|
||||
private readonly IHubContext<StatusHub> _hubContext;
|
||||
private readonly ILogger<SearchController> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SearchController(
|
||||
ILotFinderRepository repository,
|
||||
IHubContext<StatusHub> hubContext,
|
||||
ILogger<SearchController> logger)
|
||||
ILogger<SearchController> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository;
|
||||
_hubContext = hubContext;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +43,13 @@ public class SearchController : ApiControllerBase
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(CancellationToken ct)
|
||||
{
|
||||
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
|
||||
if (CurrentUserName is null)
|
||||
return Unauthorized();
|
||||
|
||||
var searches = await _repository.GetUserSearchesAsync(CurrentUserName, ct);
|
||||
var viewModels = searches
|
||||
.OrderByDescending(s => s.StartDt)
|
||||
.Select(s => new SearchViewModel(s));
|
||||
@@ -82,9 +89,13 @@ public class SearchController : ApiControllerBase
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/copy")]
|
||||
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SearchViewModel>> CopySearch(int id, CancellationToken ct)
|
||||
{
|
||||
if (CurrentUserName is null)
|
||||
return Unauthorized();
|
||||
|
||||
var original = await _repository.GetSearchAsync(id, ct);
|
||||
if (original is null)
|
||||
{
|
||||
@@ -95,7 +106,7 @@ public class SearchController : ApiControllerBase
|
||||
var copy = new Search
|
||||
{
|
||||
Id = 0,
|
||||
UserName = CurrentUserName!,
|
||||
UserName = CurrentUserName,
|
||||
Name = original.Name,
|
||||
Status = SearchStatus.New,
|
||||
SubmitDt = null,
|
||||
@@ -112,12 +123,16 @@ public class SearchController : ApiControllerBase
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(int), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<int>> CreateSearch(
|
||||
[FromBody] SearchViewModel viewModel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (CurrentUserName is null)
|
||||
return Unauthorized();
|
||||
|
||||
var search = viewModel.ToEntity();
|
||||
search.UserName = CurrentUserName!;
|
||||
search.UserName = CurrentUserName;
|
||||
|
||||
var searchId = await _repository.SubmitSearchAsync(search, ct);
|
||||
|
||||
@@ -127,10 +142,10 @@ public class SearchController : ApiControllerBase
|
||||
var searchUpdate = new SearchUpdate
|
||||
{
|
||||
Id = searchId,
|
||||
UserName = CurrentUserName!,
|
||||
UserName = CurrentUserName,
|
||||
Name = search.Name,
|
||||
Status = search.Status,
|
||||
Timestamp = DateTime.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow().DateTime
|
||||
};
|
||||
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using JdeScoping.Api.Hubs;
|
||||
using JdeScoping.Api.Mapping;
|
||||
using JdeScoping.Api.Options;
|
||||
using JdeScoping.Api.Services;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
@@ -35,6 +36,12 @@ public static class ApiDependencyInjection
|
||||
// Register memory cache for file downloads
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Register TimeProvider for testability (allows mocking DateTime.UtcNow)
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Register mappers
|
||||
services.AddSingleton<IPipelineMapper, PipelineMapper>();
|
||||
|
||||
// Configure SignalR
|
||||
services.AddSignalR();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using JdeScoping.Core.ViewModels;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Api.Hubs;
|
||||
@@ -9,17 +10,17 @@ namespace JdeScoping.Api.Hubs;
|
||||
/// </summary>
|
||||
public class StatusHub : Hub
|
||||
{
|
||||
private static StatusUpdateViewModel _cachedStatus = new()
|
||||
{
|
||||
Message = "Unknown",
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
private const string StatusCacheKey = "StatusHub_CachedStatus";
|
||||
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<StatusHub> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public StatusHub(ILogger<StatusHub> logger)
|
||||
public StatusHub(IMemoryCache cache, ILogger<StatusHub> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,7 +30,7 @@ public class StatusHub : Hub
|
||||
/// <param name="statusUpdate">Status update to broadcast</param>
|
||||
public async Task SetStatus(StatusUpdateViewModel statusUpdate)
|
||||
{
|
||||
_cachedStatus = statusUpdate;
|
||||
_cache.Set(StatusCacheKey, statusUpdate);
|
||||
await Clients.All.SendAsync("statusUpdate", statusUpdate);
|
||||
_logger.LogDebug("Status updated: {Message}", statusUpdate.Message);
|
||||
}
|
||||
@@ -40,7 +41,11 @@ public class StatusHub : Hub
|
||||
/// <returns>The most recent status update</returns>
|
||||
public StatusUpdateViewModel GetCachedStatus()
|
||||
{
|
||||
return _cachedStatus;
|
||||
return _cache.GetOrCreate(StatusCacheKey, _ => new StatusUpdateViewModel
|
||||
{
|
||||
Message = "Unknown",
|
||||
Timestamp = _timeProvider.GetUtcNow().DateTime
|
||||
})!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.Core.Models.Pipelines;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
|
||||
namespace JdeScoping.Api.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Mapper for pipeline configuration to DTOs.
|
||||
/// </summary>
|
||||
public interface IPipelineMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a pipeline configuration to its DTO representation.
|
||||
/// </summary>
|
||||
PipelineConfigDto MapToDto(string name, PipelineConfig config, ScheduleDefaults defaults);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective interval for a schedule, applying defaults if not specified.
|
||||
/// </summary>
|
||||
int GetEffectiveInterval(ScheduleConfig? config, ScheduleDefaults defaults, UpdateTypes updateType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schedule configuration for a specific update type.
|
||||
/// </summary>
|
||||
ScheduleConfig? GetScheduleConfig(PipelineConfig config, UpdateTypes updateType);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.Core.Models.Pipelines;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
|
||||
namespace JdeScoping.Api.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Maps pipeline configuration to DTOs.
|
||||
/// </summary>
|
||||
public class PipelineMapper : IPipelineMapper
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public PipelineConfigDto MapToDto(
|
||||
string name,
|
||||
PipelineConfig config,
|
||||
ScheduleDefaults defaults)
|
||||
{
|
||||
var source = new PipelineSourceDto(
|
||||
config.Source.Connection,
|
||||
Truncate(config.Source.Query),
|
||||
Truncate(config.Source.MassQuery),
|
||||
config.Source.Query,
|
||||
config.Source.MassQuery,
|
||||
config.Source.Parameters?.Select(p => new PipelineParameterDto(
|
||||
p.Key, p.Value.Format, p.Value.Source)).ToList() ?? []);
|
||||
|
||||
var matchCols = config.Destination.MatchColumns?.ToList();
|
||||
var destination = new PipelineDestinationDto(
|
||||
config.Destination.Table,
|
||||
matchCols?.Count > 0 ? "BulkMerge" : "BulkImport",
|
||||
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, 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,
|
||||
source,
|
||||
destination,
|
||||
schedules,
|
||||
config.PreScripts?.Count ?? 0,
|
||||
config.PostScripts?.Count ?? 0,
|
||||
config.PreScripts,
|
||||
config.PostScripts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ScheduleConfig? GetScheduleConfig(
|
||||
PipelineConfig config,
|
||||
UpdateTypes updateType) => updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => config.Schedules?.Mass,
|
||||
UpdateTypes.Daily => config.Schedules?.Daily,
|
||||
UpdateTypes.Hourly => config.Schedules?.Hourly,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetEffectiveInterval(
|
||||
ScheduleConfig? config,
|
||||
ScheduleDefaults defaults,
|
||||
UpdateTypes updateType)
|
||||
{
|
||||
if (config?.IntervalMinutes > 0)
|
||||
return config.IntervalMinutes;
|
||||
|
||||
return updateType switch
|
||||
{
|
||||
UpdateTypes.Mass => defaults.Mass.IntervalMinutes,
|
||||
UpdateTypes.Daily => defaults.Daily.IntervalMinutes,
|
||||
UpdateTypes.Hourly => defaults.Hourly.IntervalMinutes,
|
||||
_ => 60
|
||||
};
|
||||
}
|
||||
|
||||
private static PipelineScheduleDto MapSchedule(
|
||||
ScheduleConfig? config,
|
||||
ScheduleConfig defaults,
|
||||
string? query,
|
||||
List<PipelineParameterDto> parameters,
|
||||
List<string>? preScripts,
|
||||
List<string>? postScripts)
|
||||
{
|
||||
return new PipelineScheduleDto(
|
||||
config?.Enabled ?? defaults.Enabled,
|
||||
config?.IntervalMinutes > 0 ? config.IntervalMinutes : defaults.IntervalMinutes,
|
||||
config?.PrePurge ?? defaults.PrePurge,
|
||||
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,
|
||||
query,
|
||||
parameters,
|
||||
preScripts,
|
||||
postScripts);
|
||||
}
|
||||
|
||||
private static string? Truncate(string? value, int maxLength = 100) =>
|
||||
value is null ? null :
|
||||
value.Length <= maxLength ? value : value[..maxLength] + "...";
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Client.Auth;
|
||||
|
||||
@@ -10,16 +11,21 @@ namespace JdeScoping.Client.Auth;
|
||||
/// Works with cookie-based authentication where the browser automatically
|
||||
/// sends cookies with each request.
|
||||
/// </summary>
|
||||
public class AuthStateProvider : AuthenticationStateProvider
|
||||
public class AuthStateProvider : AuthenticationStateProvider, IAuthStateProvider
|
||||
{
|
||||
private readonly IUserStorageService _userStorage;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<AuthStateProvider>? _logger;
|
||||
private readonly ClaimsPrincipal _anonymous = new(new ClaimsIdentity());
|
||||
|
||||
public AuthStateProvider(IUserStorageService userStorage, HttpClient httpClient)
|
||||
public AuthStateProvider(
|
||||
IUserStorageService userStorage,
|
||||
HttpClient httpClient,
|
||||
ILogger<AuthStateProvider>? logger = null)
|
||||
{
|
||||
_userStorage = userStorage;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
@@ -56,9 +62,9 @@ public class AuthStateProvider : AuthenticationStateProvider
|
||||
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Network error or other issue - treat as not authenticated
|
||||
_logger?.LogWarning(ex, "Session validation failed, treating as unauthenticated");
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
|
||||
namespace JdeScoping.Client.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing authentication state in the Blazor client.
|
||||
/// Extracted from AuthStateProvider for testability.
|
||||
/// </summary>
|
||||
public interface IAuthStateProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Called after successful login to update auth state and persist user info.
|
||||
/// </summary>
|
||||
/// <param name="user">Authenticated user info from the server.</param>
|
||||
Task MarkUserAsAuthenticated(UserInfoDto user);
|
||||
|
||||
/// <summary>
|
||||
/// Notifies that authentication state has changed, triggering a re-evaluation.
|
||||
/// </summary>
|
||||
void NotifyAuthenticationStateChanged();
|
||||
|
||||
/// <summary>
|
||||
/// Logs out the user by removing cached data and notifying of state change.
|
||||
/// </summary>
|
||||
Task LogoutAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current username from the cached user info.
|
||||
/// </summary>
|
||||
/// <returns>The username if authenticated, null otherwise.</returns>
|
||||
Task<string?> GetUsernameAsync();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@namespace JdeScoping.Client.Components.Admin
|
||||
@using JdeScoping.Core.Models.Pipelines
|
||||
@using JdeScoping.Core.Models.Enums
|
||||
@using JdeScoping.Client.Helpers
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenRow AlignItems="AlignItems.Center" class="rz-mb-3">
|
||||
@@ -65,7 +66,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(Config.Query))
|
||||
{
|
||||
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Query</RadzenText>
|
||||
<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>
|
||||
<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;">@SqlFormatHelper.FormatSql(Config.Query)</pre>
|
||||
}
|
||||
|
||||
@if (Config.PreScripts?.Count > 0)
|
||||
@@ -75,7 +76,7 @@
|
||||
{
|
||||
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>
|
||||
<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;">@SqlFormatHelper.FormatSql(script)</pre>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@
|
||||
{
|
||||
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>
|
||||
<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;">@SqlFormatHelper.FormatSql(script)</pre>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,50 +113,4 @@
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@namespace JdeScoping.Client.Components.Admin
|
||||
@using JdeScoping.Client.Helpers
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@if (Visible)
|
||||
@@ -83,55 +84,7 @@
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public string? Sql { get; set; }
|
||||
|
||||
private string FormattedSql => FormatSql(Sql);
|
||||
|
||||
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)
|
||||
{
|
||||
// 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 string FormattedSql => SqlFormatHelper.FormatSql(Sql);
|
||||
|
||||
private async Task CopyToClipboard()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Radzen;
|
||||
|
||||
namespace JdeScoping.Client.Components.FilterPanels;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for filter panels that use autocomplete search to select items.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of items displayed in the grid.</typeparam>
|
||||
public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where TItem : class
|
||||
{
|
||||
[Inject]
|
||||
protected DialogService DialogService { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The list of items displayed in the grid.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<TItem> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Callback when the items list changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<List<TItem>> ItemsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel is in read-only mode.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The current search text.
|
||||
/// </summary>
|
||||
protected string SearchText { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The search results from the API.
|
||||
/// </summary>
|
||||
protected List<TItem> SearchResults { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The currently selected item from search results.
|
||||
/// </summary>
|
||||
protected TItem? SelectedItem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the title displayed in the panel header.
|
||||
/// </summary>
|
||||
protected abstract string PanelTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the placeholder text for the autocomplete input.
|
||||
/// </summary>
|
||||
protected abstract string SearchPlaceholder { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the label for the autocomplete form field.
|
||||
/// </summary>
|
||||
protected abstract string SearchFieldLabel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the property name used for the autocomplete text display.
|
||||
/// </summary>
|
||||
protected abstract string AutocompleteTextProperty { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confirmation message for clearing data.
|
||||
/// </summary>
|
||||
protected abstract string ClearConfirmMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Performs the search API call and returns matching items.
|
||||
/// </summary>
|
||||
protected abstract Task<List<TItem>> SearchApiAsync(string filter);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique key value for an item.
|
||||
/// </summary>
|
||||
protected abstract object GetItemKey(TItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display text value for an item (used for matching in autocomplete).
|
||||
/// </summary>
|
||||
protected abstract string GetDisplayText(TItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the autocomplete search.
|
||||
/// </summary>
|
||||
protected async Task OnSearchAsync(LoadDataArgs args)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
SearchResults = await SearchApiAsync(args.Filter);
|
||||
}
|
||||
else
|
||||
{
|
||||
SearchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles selection from the autocomplete.
|
||||
/// </summary>
|
||||
protected void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
SelectedItem = SearchResults.FirstOrDefault(i => GetDisplayText(i) == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the selected item to the list.
|
||||
/// </summary>
|
||||
protected async Task AddItemAsync()
|
||||
{
|
||||
if (SelectedItem != null)
|
||||
{
|
||||
var selectedKey = GetItemKey(SelectedItem);
|
||||
var isDuplicate = Items.Any(i => GetItemKey(i).Equals(selectedKey));
|
||||
|
||||
if (!isDuplicate)
|
||||
{
|
||||
Items.Add(SelectedItem);
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
}
|
||||
SearchText = "";
|
||||
SelectedItem = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the list.
|
||||
/// </summary>
|
||||
protected async Task DeleteItem(TItem item)
|
||||
{
|
||||
Items.Remove(item);
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all items after confirmation.
|
||||
/// </summary>
|
||||
protected async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm(ClearConfirmMessage, "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
Items.Clear();
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
@* Component lot filter panel with upload/download/clear functionality *@
|
||||
@inherits FileUploadFilterPanelBase<ComponentLotViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Core.ViewModels
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Component Lot</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
|
||||
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="componentLotFileInput" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
|
||||
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
</RadzenStack>
|
||||
}
|
||||
</RadzenStack>
|
||||
|
||||
<RadzenDataGrid Data="@ComponentLots" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="LotNumber" Title="Lot Number" />
|
||||
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="ItemNumber" Title="Item Number" />
|
||||
@@ -28,86 +27,46 @@
|
||||
</RadzenDataGrid>
|
||||
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
|
||||
<strong># of component lots: @ComponentLots.Count</strong>
|
||||
<strong>@CountLabel: @Items.Count</strong>
|
||||
</RadzenText>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Component Lot";
|
||||
protected override string CountLabel => "# of component lots";
|
||||
protected override string FileInputId => "componentLotFileInput";
|
||||
protected override string TemplateFilename => "componentlots_template.xlsx";
|
||||
protected override string EntityName => "component lots";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all component lots?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<ComponentLotViewModel>> ComponentLotsChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isUploading;
|
||||
|
||||
private async Task DownloadTemplateAsync()
|
||||
protected override async Task<byte[]?> DownloadTemplateApiAsync()
|
||||
{
|
||||
var lotData = ComponentLots.Select(cl => new LotViewModel { LotNumber = cl.LotNumber, ItemNumber = cl.ItemNumber }).ToList().AsReadOnly();
|
||||
var lotData = Items.Select(cl => new LotViewModel { LotNumber = cl.LotNumber, ItemNumber = cl.ItemNumber }).ToList().AsReadOnly();
|
||||
var result = await FileApi.DownloadComponentLotsTemplateAsync(lotData);
|
||||
byte[]? bytes = null;
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "componentlots_template.xlsx", bytes); },
|
||||
data => { bytes = data; },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
protected override async Task<List<ComponentLotViewModel>?> UploadFileApiAsync(Stream stream, string filename)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('componentLotFileInput').click()");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var result = await FileApi.UploadComponentLotsAsync(stream, e.File.Name);
|
||||
|
||||
result.Switch(
|
||||
lots =>
|
||||
{
|
||||
ComponentLots.Clear();
|
||||
ComponentLots.AddRange(lots.Select(l => new ComponentLotViewModel { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber }));
|
||||
_ = ComponentLotsChanged.InvokeAsync(ComponentLots);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {lots.Count} component lots.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all component lots?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
ComponentLots.Clear();
|
||||
await ComponentLotsChanged.InvokeAsync(ComponentLots);
|
||||
}
|
||||
var result = await FileApi.UploadComponentLotsAsync(stream, filename);
|
||||
List<ComponentLotViewModel>? items = null;
|
||||
result.Switch(
|
||||
lots => { items = lots.Select(l => new ComponentLotViewModel { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber }).ToList(); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Microsoft.JSInterop;
|
||||
using Radzen;
|
||||
|
||||
namespace JdeScoping.Client.Components.FilterPanels;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for filter panels that use file upload for data input.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of items displayed in the grid.</typeparam>
|
||||
public abstract class FileUploadFilterPanelBase<TItem> : ComponentBase where TItem : class
|
||||
{
|
||||
[Inject]
|
||||
protected DialogService DialogService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected NotificationService NotificationService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The list of items displayed in the grid.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<TItem> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Callback when the items list changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<List<TItem>> ItemsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel is in read-only mode.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether a file upload is in progress.
|
||||
/// </summary>
|
||||
protected bool IsUploading { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the title displayed in the panel header.
|
||||
/// </summary>
|
||||
protected abstract string PanelTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count label text (e.g., "# of work orders").
|
||||
/// </summary>
|
||||
protected abstract string CountLabel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTML element ID for the file input.
|
||||
/// </summary>
|
||||
protected abstract string FileInputId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the filename for the downloaded template.
|
||||
/// </summary>
|
||||
protected abstract string TemplateFilename { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity name for notifications (e.g., "work orders").
|
||||
/// </summary>
|
||||
protected abstract string EntityName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confirmation message for clearing data.
|
||||
/// </summary>
|
||||
protected abstract string ClearConfirmMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the template file from the API.
|
||||
/// </summary>
|
||||
protected abstract Task<byte[]?> DownloadTemplateApiAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the file and returns the parsed items.
|
||||
/// </summary>
|
||||
protected abstract Task<List<TItem>?> UploadFileApiAsync(Stream stream, string filename);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the template.
|
||||
/// </summary>
|
||||
protected async Task DownloadTemplateAsync()
|
||||
{
|
||||
var bytes = await DownloadTemplateApiAsync();
|
||||
if (bytes != null)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", TemplateFilename, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the file input click.
|
||||
/// </summary>
|
||||
protected async Task TriggerFileInput()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", FileInputId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles file selection and upload.
|
||||
/// </summary>
|
||||
protected async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
IsUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var uploadedItems = await UploadFileApiAsync(stream, e.File.Name);
|
||||
|
||||
if (uploadedItems != null)
|
||||
{
|
||||
Items.Clear();
|
||||
Items.AddRange(uploadedItems);
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {uploadedItems.Count} {EntityName}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all items after confirmation.
|
||||
/// </summary>
|
||||
protected async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm(ClearConfirmMessage, "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
Items.Clear();
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@* Item number filter panel with autocomplete and grid *@
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using Microsoft.JSInterop
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@@ -128,7 +129,21 @@
|
||||
{
|
||||
var result = await FileApi.DownloadItemsTemplateAsync(Items.AsReadOnly());
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "items_template.xlsx", bytes); },
|
||||
bytes =>
|
||||
{
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", "items_template.xlsx", bytes);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Console.WriteLine($"JS interop failed during template download: {ex.Message}");
|
||||
}
|
||||
});
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
@@ -139,7 +154,7 @@
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('itemNumberFileInput').click()");
|
||||
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", "itemNumberFileInput");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
@@ -157,7 +172,18 @@
|
||||
{
|
||||
Items.Clear();
|
||||
Items.AddRange(items);
|
||||
_ = ItemsChanged.InvokeAsync(Items);
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ItemsChanged.InvokeAsync(Items);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"EventCallback invocation failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {items.Count} items.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@* Operator filter panel with autocomplete and grid *@
|
||||
@inherits AutocompleteFilterPanelBase<OperatorViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Extensions
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject DialogService DialogService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Operator</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
@@ -17,20 +17,20 @@
|
||||
{
|
||||
<RadzenRow Gap="0.5rem" class="rz-mb-3">
|
||||
<RadzenColumn Size="10">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="FullName"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search operators (3+ chars)..."
|
||||
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
|
||||
Style="width: 100%;" Change="@OnItemSelected" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="2">
|
||||
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
|
||||
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
|
||||
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
}
|
||||
|
||||
<RadzenDataGrid Data="@Operators" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="OperatorViewModel" Property="AddressNumber" Title="Address Number" Width="150px" />
|
||||
<RadzenDataGridColumn TItem="OperatorViewModel" Property="UserID" Title="User Name" Width="150px" />
|
||||
@@ -48,75 +48,27 @@
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<OperatorViewModel> Operators { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Operator";
|
||||
protected override string SearchPlaceholder => "Search operators (3+ chars)...";
|
||||
protected override string SearchFieldLabel => "Name";
|
||||
protected override string AutocompleteTextProperty => "FullName";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all operators?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<OperatorViewModel>> OperatorsChanged { get; set; }
|
||||
protected override object GetItemKey(OperatorViewModel item) => item.UserId;
|
||||
protected override string GetDisplayText(OperatorViewModel item) => item.FullName;
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
private string _searchText = "";
|
||||
private List<OperatorViewModel> _searchResults = [];
|
||||
private OperatorViewModel? _selectedItem;
|
||||
|
||||
private async Task OnSearchAsync(LoadDataArgs args)
|
||||
protected override async Task<List<OperatorViewModel>> SearchApiAsync(string filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
var result = await LookupApi.FindOperatorsAsync(args.Filter);
|
||||
result.Switch(
|
||||
jdeUsers => { _searchResults = jdeUsers.ToClientOperatorList(); },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
_selectedItem = _searchResults.FirstOrDefault(i => i.FullName == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
if (_selectedItem != null && !Operators.Any(i => i.UserId == _selectedItem.UserId))
|
||||
{
|
||||
Operators.Add(_selectedItem);
|
||||
await OperatorsChanged.InvokeAsync(Operators);
|
||||
}
|
||||
_searchText = "";
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
private async Task DeleteItem(OperatorViewModel item)
|
||||
{
|
||||
Operators.Remove(item);
|
||||
await OperatorsChanged.InvokeAsync(Operators);
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all operators?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
Operators.Clear();
|
||||
await OperatorsChanged.InvokeAsync(Operators);
|
||||
}
|
||||
var result = await LookupApi.FindOperatorsAsync(filter);
|
||||
List<OperatorViewModel> items = [];
|
||||
result.Switch(
|
||||
jdeUsers => { items = jdeUsers.ToClientOperatorList(); },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
@* Part operation/MIS filter panel with upload/download/clear functionality *@
|
||||
@inherits FileUploadFilterPanelBase<PartOperationViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter By Item/Operation/MIS</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
|
||||
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="partOperationFileInput" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
|
||||
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
</RadzenStack>
|
||||
}
|
||||
</RadzenStack>
|
||||
|
||||
<RadzenDataGrid Data="@PartOperations" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
|
||||
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
|
||||
@@ -29,85 +28,45 @@
|
||||
</RadzenDataGrid>
|
||||
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
|
||||
<strong># of item / operations: @PartOperations.Count</strong>
|
||||
<strong>@CountLabel: @Items.Count</strong>
|
||||
</RadzenText>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<PartOperationViewModel> PartOperations { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter By Item/Operation/MIS";
|
||||
protected override string CountLabel => "# of item / operations";
|
||||
protected override string FileInputId => "partOperationFileInput";
|
||||
protected override string TemplateFilename => "partoperations_template.xlsx";
|
||||
protected override string EntityName => "part operations";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all item/operation/MIS entries?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<PartOperationViewModel>> PartOperationsChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isUploading;
|
||||
|
||||
private async Task DownloadTemplateAsync()
|
||||
protected override async Task<byte[]?> DownloadTemplateApiAsync()
|
||||
{
|
||||
var result = await FileApi.DownloadPartOperationsTemplateAsync(PartOperations.AsReadOnly());
|
||||
var result = await FileApi.DownloadPartOperationsTemplateAsync(Items.AsReadOnly());
|
||||
byte[]? bytes = null;
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "partoperations_template.xlsx", bytes); },
|
||||
data => { bytes = data; },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
protected override async Task<List<PartOperationViewModel>?> UploadFileApiAsync(Stream stream, string filename)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('partOperationFileInput').click()");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var result = await FileApi.UploadPartOperationsAsync(stream, e.File.Name);
|
||||
|
||||
result.Switch(
|
||||
partOperations =>
|
||||
{
|
||||
PartOperations.Clear();
|
||||
PartOperations.AddRange(partOperations);
|
||||
_ = PartOperationsChanged.InvokeAsync(PartOperations);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {partOperations.Count} part operations.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all item/operation/MIS entries?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
PartOperations.Clear();
|
||||
await PartOperationsChanged.InvokeAsync(PartOperations);
|
||||
}
|
||||
var result = await FileApi.UploadPartOperationsAsync(stream, filename);
|
||||
List<PartOperationViewModel>? items = null;
|
||||
result.Switch(
|
||||
partOperations => { items = partOperations.ToList(); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@* Profit center filter panel with autocomplete and grid *@
|
||||
@inherits AutocompleteFilterPanelBase<ProfitCenterViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject DialogService DialogService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Profit Center</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
@@ -16,20 +16,20 @@
|
||||
{
|
||||
<RadzenRow Gap="0.5rem" class="rz-mb-3">
|
||||
<RadzenColumn Size="10">
|
||||
<RadzenFormField Text="Profit Center" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search profit centers (3+ chars)..."
|
||||
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
|
||||
Style="width: 100%;" Change="@OnItemSelected" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="2">
|
||||
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
|
||||
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
|
||||
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
}
|
||||
|
||||
<RadzenDataGrid Data="@ProfitCenters" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" Width="150px" />
|
||||
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
|
||||
@@ -46,75 +46,27 @@
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Profit Center";
|
||||
protected override string SearchPlaceholder => "Search profit centers (3+ chars)...";
|
||||
protected override string SearchFieldLabel => "Profit Center";
|
||||
protected override string AutocompleteTextProperty => "Code";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all profit centers?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged { get; set; }
|
||||
protected override object GetItemKey(ProfitCenterViewModel item) => item.Code;
|
||||
protected override string GetDisplayText(ProfitCenterViewModel item) => item.Code;
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
private string _searchText = "";
|
||||
private List<ProfitCenterViewModel> _searchResults = [];
|
||||
private ProfitCenterViewModel? _selectedItem;
|
||||
|
||||
private async Task OnSearchAsync(LoadDataArgs args)
|
||||
protected override async Task<List<ProfitCenterViewModel>> SearchApiAsync(string filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
var result = await LookupApi.FindProfitCentersAsync(args.Filter);
|
||||
result.Switch(
|
||||
profitCenters => { _searchResults = profitCenters.ToList(); },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
if (_selectedItem != null && !ProfitCenters.Any(i => i.Code == _selectedItem.Code))
|
||||
{
|
||||
ProfitCenters.Add(_selectedItem);
|
||||
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
|
||||
}
|
||||
_searchText = "";
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
private async Task DeleteItem(ProfitCenterViewModel item)
|
||||
{
|
||||
ProfitCenters.Remove(item);
|
||||
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all profit centers?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
ProfitCenters.Clear();
|
||||
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
|
||||
}
|
||||
var result = await LookupApi.FindProfitCentersAsync(filter);
|
||||
List<ProfitCenterViewModel> items = [];
|
||||
result.Switch(
|
||||
profitCenters => { items = profitCenters.ToList(); },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@* Work center filter panel with autocomplete and grid *@
|
||||
@inherits AutocompleteFilterPanelBase<WorkCenterViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject ILookupApiClient LookupApi
|
||||
@inject DialogService DialogService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Center</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
@@ -16,20 +16,20 @@
|
||||
{
|
||||
<RadzenRow Gap="0.5rem" class="rz-mb-3">
|
||||
<RadzenColumn Size="10">
|
||||
<RadzenFormField Text="Work Center" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search work centers (3+ chars)..."
|
||||
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
|
||||
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
|
||||
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
|
||||
Style="width: 100%;" Change="@OnItemSelected" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="2">
|
||||
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
|
||||
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
|
||||
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
}
|
||||
|
||||
<RadzenDataGrid Data="@WorkCenters" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Code" Title="Work Center" Width="150px" />
|
||||
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Description" Title="Description" />
|
||||
@@ -46,75 +46,27 @@
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Work Center";
|
||||
protected override string SearchPlaceholder => "Search work centers (3+ chars)...";
|
||||
protected override string SearchFieldLabel => "Work Center";
|
||||
protected override string AutocompleteTextProperty => "Code";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all work centers?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<WorkCenterViewModel>> WorkCentersChanged { get; set; }
|
||||
protected override object GetItemKey(WorkCenterViewModel item) => item.Code;
|
||||
protected override string GetDisplayText(WorkCenterViewModel item) => item.Code;
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
private string _searchText = "";
|
||||
private List<WorkCenterViewModel> _searchResults = [];
|
||||
private WorkCenterViewModel? _selectedItem;
|
||||
|
||||
private async Task OnSearchAsync(LoadDataArgs args)
|
||||
protected override async Task<List<WorkCenterViewModel>> SearchApiAsync(string filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
|
||||
{
|
||||
var result = await LookupApi.FindWorkCentersAsync(args.Filter);
|
||||
result.Switch(
|
||||
workCenters => { _searchResults = workCenters.ToList(); },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; },
|
||||
_ => { _searchResults = []; }
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemSelected(object value)
|
||||
{
|
||||
if (value is string text && !string.IsNullOrEmpty(text))
|
||||
{
|
||||
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddItemAsync()
|
||||
{
|
||||
if (_selectedItem != null && !WorkCenters.Any(i => i.Code == _selectedItem.Code))
|
||||
{
|
||||
WorkCenters.Add(_selectedItem);
|
||||
await WorkCentersChanged.InvokeAsync(WorkCenters);
|
||||
}
|
||||
_searchText = "";
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
private async Task DeleteItem(WorkCenterViewModel item)
|
||||
{
|
||||
WorkCenters.Remove(item);
|
||||
await WorkCentersChanged.InvokeAsync(WorkCenters);
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work centers?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
WorkCenters.Clear();
|
||||
await WorkCentersChanged.InvokeAsync(WorkCenters);
|
||||
}
|
||||
var result = await LookupApi.FindWorkCentersAsync(filter);
|
||||
List<WorkCenterViewModel> items = [];
|
||||
result.Switch(
|
||||
workCenters => { items = workCenters.ToList(); },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { },
|
||||
_ => { }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
@* Work order filter panel with upload/download/clear functionality *@
|
||||
@inherits FileUploadFilterPanelBase<WorkOrderViewModel>
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@inject IFileApiClient FileApi
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Order</RadzenText>
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
|
||||
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="workOrderFileInput" />
|
||||
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
|
||||
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
|
||||
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
|
||||
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
|
||||
</RadzenStack>
|
||||
}
|
||||
</RadzenStack>
|
||||
|
||||
<RadzenDataGrid Data="@WorkOrders" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<RadzenDataGrid Data="@Items" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="WorkOrderNumber" Title="Work Order Number" />
|
||||
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="ItemNumber" Title="Item Number" />
|
||||
@@ -27,85 +26,45 @@
|
||||
</RadzenDataGrid>
|
||||
|
||||
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
|
||||
<strong># of work orders: @WorkOrders.Count</strong>
|
||||
<strong>@CountLabel: @Items.Count</strong>
|
||||
</RadzenText>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
|
||||
protected override string PanelTitle => "Filter by Work Order";
|
||||
protected override string CountLabel => "# of work orders";
|
||||
protected override string FileInputId => "workOrderFileInput";
|
||||
protected override string TemplateFilename => "workorders_template.xlsx";
|
||||
protected override string EntityName => "work orders";
|
||||
protected override string ClearConfirmMessage => "Are you sure you want to clear all work orders?";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isUploading;
|
||||
|
||||
private async Task DownloadTemplateAsync()
|
||||
protected override async Task<byte[]?> DownloadTemplateApiAsync()
|
||||
{
|
||||
var result = await FileApi.DownloadWorkOrdersTemplateAsync(WorkOrders.AsReadOnly());
|
||||
var result = await FileApi.DownloadWorkOrdersTemplateAsync(Items.AsReadOnly());
|
||||
byte[]? bytes = null;
|
||||
result.Switch(
|
||||
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "workorders_template.xlsx", bytes); },
|
||||
data => { bytes = data; },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task TriggerFileInput()
|
||||
protected override async Task<List<WorkOrderViewModel>?> UploadFileApiAsync(Stream stream, string filename)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('workOrderFileInput').click()");
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (e.File == null) return;
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
|
||||
var result = await FileApi.UploadWorkOrdersAsync(stream, e.File.Name);
|
||||
|
||||
result.Switch(
|
||||
workOrders =>
|
||||
{
|
||||
WorkOrders.Clear();
|
||||
WorkOrders.AddRange(workOrders);
|
||||
_ = WorkOrdersChanged.InvokeAsync(WorkOrders);
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {workOrders.Count} work orders.");
|
||||
},
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearDataAsync()
|
||||
{
|
||||
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work orders?", "Confirm Clear");
|
||||
if (confirmed == true)
|
||||
{
|
||||
WorkOrders.Clear();
|
||||
await WorkOrdersChanged.InvokeAsync(WorkOrders);
|
||||
}
|
||||
var result = await FileApi.UploadWorkOrdersAsync(stream, filename);
|
||||
List<WorkOrderViewModel>? items = null;
|
||||
result.Switch(
|
||||
workOrders => { items = workOrders.ToList(); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
|
||||
);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
@*
|
||||
FilterVisibilityManager.razor - Filter panel visibility controller.
|
||||
|
||||
Manages which filter panels are visible based on the selected search type.
|
||||
Cascades itself to child components so they can check visibility.
|
||||
*@
|
||||
@namespace JdeScoping.Client.Components.Search
|
||||
|
||||
<CascadingValue Value="this">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public SearchCriteriaViewModel Criteria { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ValidCombination?> OnSearchTypeChanged { get; set; }
|
||||
|
||||
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
|
||||
private int? _selectedSearchType;
|
||||
|
||||
public int? SelectedSearchType
|
||||
{
|
||||
get => _selectedSearchType;
|
||||
set
|
||||
{
|
||||
if (_selectedSearchType != value)
|
||||
{
|
||||
_selectedSearchType = value;
|
||||
var combo = _validCombinations.FirstOrDefault(c => c.Id == value);
|
||||
if (combo != null)
|
||||
{
|
||||
UpdateFilterVisibility(combo);
|
||||
}
|
||||
OnSearchTypeChanged.InvokeAsync(combo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ValidCombination> ValidCombinations => _validCombinations;
|
||||
|
||||
// Filter visibility flags
|
||||
public bool ShowTimespan { get; private set; }
|
||||
public bool ShowWorkOrder { get; private set; }
|
||||
public bool ShowItemNumber { get; private set; }
|
||||
public bool ShowProfitCenter { get; private set; }
|
||||
public bool ShowWorkCenter { get; private set; }
|
||||
public bool ShowComponentLot { get; private set; }
|
||||
public bool ShowOperator { get; private set; }
|
||||
public bool ShowItemOperationMis { get; private set; }
|
||||
public bool ShowExtractMis { get; private set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
DetectSearchType();
|
||||
}
|
||||
|
||||
public void DetectSearchType()
|
||||
{
|
||||
bool hasTimespan = Criteria.MinimumDt.HasValue || Criteria.MaximumDt.HasValue;
|
||||
bool hasWorkOrder = Criteria.WorkOrders.Count > 0;
|
||||
bool hasItemNumber = Criteria.Items.Count > 0;
|
||||
bool hasProfitCenter = Criteria.ProfitCenters.Count > 0;
|
||||
bool hasWorkCenter = Criteria.WorkCenters.Count > 0;
|
||||
bool hasComponentLot = Criteria.ComponentLots.Count > 0;
|
||||
bool hasOperator = Criteria.Operators.Count > 0;
|
||||
bool hasPartOperation = Criteria.PartOperations.Count > 0;
|
||||
bool hasExtractMis = Criteria.ExtractMisData;
|
||||
|
||||
foreach (var combo in _validCombinations)
|
||||
{
|
||||
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
|
||||
{
|
||||
_selectedSearchType = combo.Id;
|
||||
UpdateFilterVisibility(combo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterVisibility(ValidCombination combo)
|
||||
{
|
||||
ShowTimespan = combo.Timespan;
|
||||
ShowWorkOrder = combo.WorkOrder;
|
||||
ShowItemNumber = combo.ItemNumber;
|
||||
ShowProfitCenter = combo.ProfitCenter;
|
||||
ShowWorkCenter = combo.WorkCenter;
|
||||
ShowComponentLot = combo.ComponentLot;
|
||||
ShowOperator = combo.Operator;
|
||||
ShowItemOperationMis = combo.ItemOperationMis;
|
||||
ShowExtractMis = combo.ExtractMis;
|
||||
|
||||
// Set ExtractMisData flag based on combo
|
||||
Criteria.ExtractMisData = combo.ExtractMis;
|
||||
}
|
||||
|
||||
public string? ValidateFilters()
|
||||
{
|
||||
if (ShowWorkOrder && Criteria.WorkOrders.Count == 0)
|
||||
return "At least one work order must be specified for the work order filter.";
|
||||
|
||||
if (ShowItemNumber && Criteria.Items.Count == 0)
|
||||
return "At least one item number must be specified for the item number filter.";
|
||||
|
||||
if (ShowProfitCenter && Criteria.ProfitCenters.Count == 0)
|
||||
return "At least one profit center must be specified for the profit center filter.";
|
||||
|
||||
if (ShowWorkCenter && Criteria.WorkCenters.Count == 0)
|
||||
return "At least one work center must be specified for the work center filter.";
|
||||
|
||||
if (ShowComponentLot && Criteria.ComponentLots.Count == 0)
|
||||
return "At least one component lot must be specified for the component lot filter.";
|
||||
|
||||
if (ShowOperator && Criteria.Operators.Count == 0)
|
||||
return "At least one operator must be specified for the operator filter.";
|
||||
|
||||
if (ShowItemOperationMis && Criteria.PartOperations.Count == 0)
|
||||
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@*
|
||||
SearchDetailsSection.razor - Search metadata input section.
|
||||
|
||||
Provides inputs for search name and type selection.
|
||||
Integrates with FilterVisibilityManager to show/hide filter panels based on search type.
|
||||
*@
|
||||
@namespace JdeScoping.Client.Components.Search
|
||||
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
|
||||
|
||||
<RadzenRow Gap="1rem">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Search Type" Style="width: 100%;">
|
||||
<RadzenDropDown @bind-Value="SelectedSearchType" Data="@ValidCombinations" TextProperty="Name" ValueProperty="Id"
|
||||
Placeholder="Select type" Disabled="@Search.IsReadOnly" Change="@OnSearchTypeChangedHandler" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenTextBox @bind-Value="Search.Name" Disabled="@Search.IsReadOnly" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
<ValidationMessage For="@(() => Search.Name)" class="validation-message text-danger" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Submitted At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(Search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Started At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(Search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Completed At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(Search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="User" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@Search.UserName" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Status" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@Search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {Search.StatusColor};")" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
@if (Search.HasResults)
|
||||
{
|
||||
<RadzenFormField Text=" " Style="width: 100%;">
|
||||
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@OnDownloadResults" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
}
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
</RadzenCard>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public ClientSearchViewModel Search { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public int? SelectedSearchType { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int?> SelectedSearchTypeChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<ValidCombination> ValidCombinations { get; set; } = ValidCombination.GetAll();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnDownloadResults { get; set; }
|
||||
|
||||
private async Task OnSearchTypeChangedHandler()
|
||||
{
|
||||
await SelectedSearchTypeChanged.InvokeAsync(SelectedSearchType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@*
|
||||
SignalRStatusHandler.razor - Real-time search status handler.
|
||||
|
||||
Subscribes to SignalR hub for search status updates.
|
||||
Filters updates by SearchId and raises OnStatusChanged callback.
|
||||
*@
|
||||
@namespace JdeScoping.Client.Components.Search
|
||||
@inject IHubConnectionService HubConnection
|
||||
@implements IDisposable
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int SearchId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<SearchUpdateViewModel> OnStatusChanged { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
HubConnection.OnSearchUpdate += HandleSearchUpdate;
|
||||
await HubConnection.StartAsync();
|
||||
}
|
||||
|
||||
private void HandleSearchUpdate(SearchUpdateViewModel update)
|
||||
{
|
||||
if (update.Id == SearchId)
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
await OnStatusChanged.InvokeAsync(update);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
@* Loading indicator component with optional message *@
|
||||
@*
|
||||
LoadingIndicator.razor - Reusable loading spinner component.
|
||||
|
||||
Displays a circular progress indicator with an optional message.
|
||||
Used throughout the app during async data loading operations.
|
||||
|
||||
Parameters:
|
||||
- Message: Optional text to display below the spinner.
|
||||
*@
|
||||
|
||||
<div class="loading-container">
|
||||
<RadzenProgressBarCircular ShowValue="false" Mode="ProgressBarMode.Indeterminate" Size="ProgressBarCircularSize.Large" />
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
RedirectToLogin.razor - Authentication redirect component.
|
||||
|
||||
Automatically redirects unauthenticated users to the login page,
|
||||
preserving the original URL as a return parameter.
|
||||
*@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
|
||||
@@ -37,17 +37,22 @@ public static class ViewModelMappingExtensions
|
||||
/// <summary>
|
||||
/// Maps Client SearchViewModel to Core SearchViewModel.
|
||||
/// </summary>
|
||||
public static CoreSearch ToCore(this SearchViewModel vm) => new()
|
||||
public static CoreSearch ToCore(this SearchViewModel vm)
|
||||
{
|
||||
Id = vm.Id,
|
||||
Name = vm.Name,
|
||||
UserName = vm.UserName,
|
||||
Status = Enum.TryParse<SearchStatus>(vm.Status, out var status) ? status : SearchStatus.New,
|
||||
SubmitDt = vm.SubmitDt,
|
||||
StartDt = vm.StartDt,
|
||||
EndDt = vm.EndDt,
|
||||
Criteria = vm.Criteria.ToCoreCriteria()
|
||||
};
|
||||
ArgumentNullException.ThrowIfNull(vm);
|
||||
|
||||
return new CoreSearch
|
||||
{
|
||||
Id = vm.Id,
|
||||
Name = vm.Name,
|
||||
UserName = vm.UserName,
|
||||
Status = Enum.TryParse<SearchStatus>(vm.Status, out var status) ? status : SearchStatus.New,
|
||||
SubmitDt = vm.SubmitDt,
|
||||
StartDt = vm.StartDt,
|
||||
EndDt = vm.EndDt,
|
||||
Criteria = vm.Criteria.ToCoreCriteria()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Core SearchCriteria to Client SearchCriteriaViewModel.
|
||||
@@ -102,28 +107,33 @@ public static class ViewModelMappingExtensions
|
||||
/// Maps Client SearchCriteriaViewModel to Core SearchCriteria.
|
||||
/// Client uses full view model objects; Core uses primitive lists.
|
||||
/// </summary>
|
||||
public static SearchCriteria ToCoreCriteria(this SearchCriteriaViewModel criteria) => new()
|
||||
public static SearchCriteria ToCoreCriteria(this SearchCriteriaViewModel criteria)
|
||||
{
|
||||
MinimumDt = criteria.MinimumDt,
|
||||
MaximumDt = criteria.MaximumDt,
|
||||
ExtractMisData = criteria.ExtractMisData,
|
||||
WorkOrderNumbers = criteria.WorkOrders.Select(wo => wo.WorkOrderNumber).ToList(),
|
||||
ItemNumbers = criteria.Items.Select(i => i.ItemNumber).ToList(),
|
||||
ProfitCenters = criteria.ProfitCenters.Select(pc => pc.Code).ToList(),
|
||||
WorkCenters = criteria.WorkCenters.Select(wc => wc.Code).ToList(),
|
||||
OperatorIDs = criteria.Operators.Select(o => o.UserId).ToList(),
|
||||
ComponentLotNumbers = criteria.ComponentLots
|
||||
.Select(l => new CoreLot { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber })
|
||||
.ToList(),
|
||||
PartOperations = criteria.PartOperations.ToList()
|
||||
};
|
||||
ArgumentNullException.ThrowIfNull(criteria);
|
||||
|
||||
return new SearchCriteria
|
||||
{
|
||||
MinimumDt = criteria.MinimumDt,
|
||||
MaximumDt = criteria.MaximumDt,
|
||||
ExtractMisData = criteria.ExtractMisData,
|
||||
WorkOrderNumbers = criteria.WorkOrders.Select(wo => wo.WorkOrderNumber).ToList(),
|
||||
ItemNumbers = criteria.Items.Select(i => i.ItemNumber).ToList(),
|
||||
ProfitCenters = criteria.ProfitCenters.Select(pc => pc.Code).ToList(),
|
||||
WorkCenters = criteria.WorkCenters.Select(wc => wc.Code).ToList(),
|
||||
OperatorIDs = criteria.Operators.Select(o => o.UserId).ToList(),
|
||||
ComponentLotNumbers = criteria.ComponentLots
|
||||
.Select(l => new CoreLot { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber })
|
||||
.ToList(),
|
||||
PartOperations = criteria.PartOperations.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Core JdeUserViewModel to Client OperatorViewModel.
|
||||
/// </summary>
|
||||
public static OperatorViewModel ToClientOperator(this CoreJdeUser vm) => new()
|
||||
{
|
||||
AddressNumber = (int)vm.AddressNumber,
|
||||
AddressNumber = vm.AddressNumber,
|
||||
UserId = vm.UserId,
|
||||
FullName = vm.FullName
|
||||
};
|
||||
@@ -131,12 +141,18 @@ public static class ViewModelMappingExtensions
|
||||
/// <summary>
|
||||
/// Maps a collection of Core SearchViewModels to Client SearchViewModels.
|
||||
/// </summary>
|
||||
public static List<SearchViewModel> ToClientList(this IEnumerable<CoreSearch> list) =>
|
||||
list.Select(s => s.ToClient()).ToList();
|
||||
public static List<SearchViewModel> ToClientList(this IEnumerable<CoreSearch> list)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(list);
|
||||
return list.Select(s => s.ToClient()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a collection of Core JdeUserViewModels to Client OperatorViewModels.
|
||||
/// </summary>
|
||||
public static List<OperatorViewModel> ToClientOperatorList(this IEnumerable<CoreJdeUser> list) =>
|
||||
list.Select(u => u.ToClientOperator()).ToList();
|
||||
public static List<OperatorViewModel> ToClientOperatorList(this IEnumerable<CoreJdeUser> list)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(list);
|
||||
return list.Select(u => u.ToClientOperator()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace JdeScoping.Client.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for formatting SQL queries for display.
|
||||
/// </summary>
|
||||
public static class SqlFormatHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a SQL query string for readable display by adding line breaks
|
||||
/// before major SQL clauses and formatting SELECT columns.
|
||||
/// </summary>
|
||||
/// <param name="sql">The raw SQL query string.</param>
|
||||
/// <returns>A formatted SQL string with line breaks for readability.</returns>
|
||||
public 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)
|
||||
{
|
||||
// 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}";
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
PipelineViewer.razor - ETL pipeline configuration viewer (admin).
|
||||
|
||||
Displays all configured data sync pipelines with their schedules, queries, and mappings.
|
||||
Read-only view for inspecting pipeline configuration without modifying.
|
||||
*@
|
||||
@page "/admin/pipeline-viewer"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
@*
|
||||
Login.razor - User authentication page.
|
||||
|
||||
Handles LDAP authentication with RSA-encrypted credential transmission.
|
||||
Redirects to the home page on successful login.
|
||||
*@
|
||||
@page "/login"
|
||||
@using JdeScoping.Core.Models.Auth
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Auth
|
||||
@inject IAuthApiClient AuthApi
|
||||
@inject ICryptoService CryptoService
|
||||
@inject AuthStateProvider AuthStateProvider
|
||||
@inject IAuthStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Login - JDE Scoping Tool</PageTitle>
|
||||
@@ -73,8 +80,18 @@
|
||||
{
|
||||
if (loginResult.Success && loginResult.User is not null)
|
||||
{
|
||||
// Notify auth state provider of successful login
|
||||
_ = AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to mark user as authenticated: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
|
||||
NavigationManager.NavigateTo(returnUrl);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
RefreshStatus.razor - Data cache refresh status dashboard.
|
||||
|
||||
Shows the status of JDE/CMS data synchronization jobs (hourly, daily, mass).
|
||||
Allows filtering by date range and entity name.
|
||||
*@
|
||||
@page "/refresh-status"
|
||||
@attribute [Authorize]
|
||||
@inject IRefreshStatusService RefreshStatusService
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
@*
|
||||
SearchEdit.razor - Main search creation and editing page.
|
||||
|
||||
Handles creating new searches, editing existing drafts, and viewing completed searches.
|
||||
Integrates with SignalR for real-time status updates during search execution.
|
||||
*@
|
||||
@page "/search"
|
||||
@page "/search/{Id:int}"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
@using JdeScoping.Client.Extensions
|
||||
@using JdeScoping.Client.Auth
|
||||
@using JdeScoping.Client.Components.Search
|
||||
@using Microsoft.JSInterop
|
||||
@inject ISearchApiClient SearchApi
|
||||
@inject IHubConnectionService HubConnection
|
||||
@inject AuthStateProvider AuthStateProvider
|
||||
@inject IAuthStateProvider AuthStateProvider
|
||||
@inject ISearchValidationService ValidationService
|
||||
@inject ISearchSubmissionService SubmissionService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject DialogService DialogService
|
||||
@inject NotificationService NotificationService
|
||||
@@ -34,133 +45,79 @@ else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<SignalRStatusHandler SearchId="@_search.Id" OnStatusChanged="HandleSearchUpdate" />
|
||||
|
||||
@if (_search.IsReadOnly)
|
||||
{
|
||||
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
|
||||
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
|
||||
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
|
||||
</RadzenAlert>
|
||||
}
|
||||
<FilterVisibilityManager @ref="_visibilityManager" Criteria="@_search.Criteria" OnSearchTypeChanged="OnSearchTypeChanged">
|
||||
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<!-- Validation Summary -->
|
||||
<ValidationSummary class="rz-mb-4" />
|
||||
@if (_search.IsReadOnly)
|
||||
{
|
||||
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
|
||||
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
|
||||
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
|
||||
</RadzenAlert>
|
||||
}
|
||||
|
||||
<!-- Search Details Panel -->
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
|
||||
<ValidationSummary class="rz-mb-4" />
|
||||
|
||||
<RadzenRow Gap="1rem">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Search Type" Style="width: 100%;">
|
||||
<RadzenDropDown @bind-Value="_selectedSearchType" Data="@_validCombinations" TextProperty="Name" ValueProperty="Id"
|
||||
Placeholder="Select type" Disabled="@_search.IsReadOnly" Change="@OnSearchTypeChanged" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
<SearchDetailsSection
|
||||
Search="@_search"
|
||||
@bind-SelectedSearchType="@_visibilityManager.SelectedSearchType"
|
||||
ValidCombinations="@_visibilityManager.ValidCombinations"
|
||||
OnDownloadResults="@DownloadResultsAsync" />
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="12">
|
||||
<RadzenFormField Text="Name" Style="width: 100%;">
|
||||
<RadzenTextBox @bind-Value="_search.Name" Disabled="@_search.IsReadOnly" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
<ValidationMessage For="@(() => _search.Name)" class="validation-message text-danger" />
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
@if (_visibilityManager.ShowTimespan)
|
||||
{
|
||||
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Submitted At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Started At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Completed At" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@(_search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
@if (_visibilityManager.ShowWorkOrder)
|
||||
{
|
||||
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<RadzenRow Gap="1rem" class="rz-mt-3">
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="User" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@_search.UserName" ReadOnly="true" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
<RadzenFormField Text="Status" Style="width: 100%;">
|
||||
<RadzenTextBox Value="@_search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {_search.StatusColor};")" />
|
||||
</RadzenFormField>
|
||||
</RadzenColumn>
|
||||
<RadzenColumn Size="4">
|
||||
@if (_search.HasResults)
|
||||
{
|
||||
<RadzenFormField Text=" " Style="width: 100%;">
|
||||
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@DownloadResultsAsync" Style="width: 100%;" />
|
||||
</RadzenFormField>
|
||||
}
|
||||
</RadzenColumn>
|
||||
</RadzenRow>
|
||||
</RadzenCard>
|
||||
@if (_visibilityManager.ShowItemNumber)
|
||||
{
|
||||
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
<!-- Filter Panels -->
|
||||
@if (_showTimespan)
|
||||
{
|
||||
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowProfitCenter)
|
||||
{
|
||||
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showWorkOrder)
|
||||
{
|
||||
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowWorkCenter)
|
||||
{
|
||||
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showItemNumber)
|
||||
{
|
||||
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowComponentLot)
|
||||
{
|
||||
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showProfitCenter)
|
||||
{
|
||||
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowOperator)
|
||||
{
|
||||
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showWorkCenter)
|
||||
{
|
||||
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
@if (_visibilityManager.ShowItemOperationMis)
|
||||
{
|
||||
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showComponentLot)
|
||||
{
|
||||
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showOperator)
|
||||
{
|
||||
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showItemOperationMis)
|
||||
{
|
||||
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
|
||||
}
|
||||
|
||||
@if (_showExtractMis)
|
||||
{
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
|
||||
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
}
|
||||
</EditForm>
|
||||
@if (_visibilityManager.ShowExtractMis)
|
||||
{
|
||||
<RadzenCard class="rz-mb-4">
|
||||
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
|
||||
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
|
||||
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
|
||||
</RadzenStack>
|
||||
</RadzenCard>
|
||||
}
|
||||
</EditForm>
|
||||
</FilterVisibilityManager>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -171,28 +128,15 @@ else
|
||||
public int? CopySearchId { get; set; }
|
||||
|
||||
private ClientSearchViewModel _search = new() { Criteria = new() };
|
||||
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
|
||||
private int? _selectedSearchType;
|
||||
private FilterVisibilityManager _visibilityManager = null!;
|
||||
|
||||
private bool _isLoading = true;
|
||||
private bool _isSubmitting;
|
||||
private string? _errorMessage;
|
||||
|
||||
// Filter visibility flags
|
||||
private bool _showTimespan;
|
||||
private bool _showWorkOrder;
|
||||
private bool _showItemNumber;
|
||||
private bool _showProfitCenter;
|
||||
private bool _showWorkCenter;
|
||||
private bool _showComponentLot;
|
||||
private bool _showOperator;
|
||||
private bool _showItemOperationMis;
|
||||
private bool _showExtractMis;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadSearchAsync();
|
||||
await SetupSignalRAsync();
|
||||
}
|
||||
|
||||
private async Task LoadSearchAsync()
|
||||
@@ -232,7 +176,6 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
// New search
|
||||
_search = new ClientSearchViewModel
|
||||
{
|
||||
Status = "New",
|
||||
@@ -240,12 +183,6 @@ else
|
||||
Criteria = new SearchCriteriaViewModel()
|
||||
};
|
||||
}
|
||||
|
||||
// Detect search type from criteria (only if no error)
|
||||
if (string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
DetectSearchType();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -259,92 +196,26 @@ else
|
||||
return string.Join(" ", messages);
|
||||
}
|
||||
|
||||
private void DetectSearchType()
|
||||
private void OnSearchTypeChanged(ValidCombination? combo)
|
||||
{
|
||||
var criteria = _search.Criteria;
|
||||
|
||||
bool hasTimespan = criteria.MinimumDt.HasValue || criteria.MaximumDt.HasValue;
|
||||
bool hasWorkOrder = criteria.WorkOrders.Count > 0;
|
||||
bool hasItemNumber = criteria.Items.Count > 0;
|
||||
bool hasProfitCenter = criteria.ProfitCenters.Count > 0;
|
||||
bool hasWorkCenter = criteria.WorkCenters.Count > 0;
|
||||
bool hasComponentLot = criteria.ComponentLots.Count > 0;
|
||||
bool hasOperator = criteria.Operators.Count > 0;
|
||||
bool hasPartOperation = criteria.PartOperations.Count > 0;
|
||||
bool hasExtractMis = criteria.ExtractMisData;
|
||||
|
||||
foreach (var combo in _validCombinations)
|
||||
{
|
||||
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
|
||||
{
|
||||
_selectedSearchType = combo.Id;
|
||||
UpdateFilterVisibility(combo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchTypeChanged()
|
||||
{
|
||||
var combo = _validCombinations.FirstOrDefault(c => c.Id == _selectedSearchType);
|
||||
if (combo != null)
|
||||
{
|
||||
UpdateFilterVisibility(combo);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterVisibility(ValidCombination combo)
|
||||
{
|
||||
_showTimespan = combo.Timespan;
|
||||
_showWorkOrder = combo.WorkOrder;
|
||||
_showItemNumber = combo.ItemNumber;
|
||||
_showProfitCenter = combo.ProfitCenter;
|
||||
_showWorkCenter = combo.WorkCenter;
|
||||
_showComponentLot = combo.ComponentLot;
|
||||
_showOperator = combo.Operator;
|
||||
_showItemOperationMis = combo.ItemOperationMis;
|
||||
_showExtractMis = combo.ExtractMis;
|
||||
|
||||
// Set ExtractMisData flag based on combo
|
||||
_search.Criteria.ExtractMisData = combo.ExtractMis;
|
||||
}
|
||||
|
||||
private async Task SetupSignalRAsync()
|
||||
{
|
||||
HubConnection.OnSearchUpdate += HandleSearchUpdate;
|
||||
await HubConnection.StartAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void HandleSearchUpdate(SearchUpdateViewModel update)
|
||||
{
|
||||
if (update.Id == _search.Id)
|
||||
{
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
_search.Status = update.Status;
|
||||
_search.SubmitDt = update.SubmitDt;
|
||||
_search.StartDt = update.StartDt;
|
||||
_search.EndDt = update.EndDt;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
_search.Status = update.Status;
|
||||
_search.SubmitDt = update.SubmitDt;
|
||||
_search.StartDt = update.StartDt;
|
||||
_search.EndDt = update.EndDt;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// DataAnnotationsValidator has already validated the model
|
||||
// Now perform additional custom validation
|
||||
if (_selectedSearchType == null)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filter data based on search type
|
||||
var validationError = ValidateFilters();
|
||||
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -353,24 +224,10 @@ else
|
||||
|
||||
private async Task SubmitSearchAsync()
|
||||
{
|
||||
// Manual submit button handler - validate and submit
|
||||
if (string.IsNullOrWhiteSpace(_search.Name))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedSearchType == null)
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filter data based on search type
|
||||
var validationError = ValidateFilters();
|
||||
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,15 +250,15 @@ else
|
||||
_isSubmitting = true;
|
||||
try
|
||||
{
|
||||
var result = await SearchApi.CreateSearchAsync(_search.ToCore());
|
||||
result.Switch(
|
||||
id => { NavigationManager.NavigateTo($"/search/{id}"); },
|
||||
notFound => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Search not found."); },
|
||||
validation => { NotificationService.Notify(NotificationSeverity.Error, "Validation Error", FormatValidationErrors(validation.FieldErrors)); },
|
||||
unauthorized => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
|
||||
forbidden => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
|
||||
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
|
||||
);
|
||||
var result = await SubmissionService.SubmitAsync(_search);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/search/{result.SearchId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
NotificationService.Notify(NotificationSeverity.Error, "Error", result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -409,32 +266,6 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private string? ValidateFilters()
|
||||
{
|
||||
if (_showWorkOrder && _search.Criteria.WorkOrders.Count == 0)
|
||||
return "At least one work order must be specified for the work order filter.";
|
||||
|
||||
if (_showItemNumber && _search.Criteria.Items.Count == 0)
|
||||
return "At least one item number must be specified for the item number filter.";
|
||||
|
||||
if (_showProfitCenter && _search.Criteria.ProfitCenters.Count == 0)
|
||||
return "At least one profit center must be specified for the profit center filter.";
|
||||
|
||||
if (_showWorkCenter && _search.Criteria.WorkCenters.Count == 0)
|
||||
return "At least one work center must be specified for the work center filter.";
|
||||
|
||||
if (_showComponentLot && _search.Criteria.ComponentLots.Count == 0)
|
||||
return "At least one component lot must be specified for the component lot filter.";
|
||||
|
||||
if (_showOperator && _search.Criteria.Operators.Count == 0)
|
||||
return "At least one operator must be specified for the operator filter.";
|
||||
|
||||
if (_showItemOperationMis && _search.Criteria.PartOperations.Count == 0)
|
||||
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CopySearchAsync()
|
||||
{
|
||||
NavigationManager.NavigateTo($"/search?copySearchId={_search.Id}");
|
||||
@@ -448,7 +279,17 @@ else
|
||||
{
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
_ = JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Console.WriteLine($"JS interop failed during file download: {ex.Message}");
|
||||
}
|
||||
});
|
||||
NotificationService.Notify(NotificationSeverity.Success, "Download", "Results downloaded successfully.");
|
||||
}
|
||||
else
|
||||
@@ -466,6 +307,6 @@ else
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
|
||||
// SignalRStatusHandler handles its own disposal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
SearchQueue.razor - Real-time search processing queue.
|
||||
|
||||
Displays all queued and running searches with live status updates via SignalR.
|
||||
Shows progress indicators and allows users to monitor their search execution.
|
||||
*@
|
||||
@page "/search/queue"
|
||||
@attribute [Authorize]
|
||||
@using JdeScoping.Core.ApiContracts
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@*
|
||||
Searches.razor - Search list dashboard (home page).
|
||||
|
||||
Displays all searches for the current user with filtering, sorting, and pagination.
|
||||
Allows creating new searches, viewing/editing drafts, and downloading completed results.
|
||||
*@
|
||||
@page "/"
|
||||
@page "/searches"
|
||||
@attribute [Authorize]
|
||||
|
||||
@@ -37,6 +37,7 @@ builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddScoped<IUserStorageService, UserStorageService>();
|
||||
builder.Services.AddScoped<AuthStateProvider>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
||||
builder.Services.AddScoped<IAuthStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
|
||||
|
||||
// Crypto service for login encryption
|
||||
builder.Services.AddScoped<ICryptoService, CryptoService>();
|
||||
@@ -56,4 +57,8 @@ builder.Services.AddScoped<IAuthApiClient, AuthApiClient>();
|
||||
builder.Services.AddScoped<IFileApiClient, FileApiClient>();
|
||||
builder.Services.AddScoped<IPipelineApiClient, PipelineApiClient>();
|
||||
|
||||
// Search services
|
||||
builder.Services.AddScoped<ISearchValidationService, SearchValidationService>();
|
||||
builder.Services.AddScoped<ISearchSubmissionService, SearchSubmissionService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -11,12 +11,12 @@ public class AuthService : IAuthService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ICryptoService _cryptoService;
|
||||
private readonly AuthStateProvider _authStateProvider;
|
||||
private readonly IAuthStateProvider _authStateProvider;
|
||||
|
||||
public AuthService(
|
||||
HttpClient httpClient,
|
||||
ICryptoService cryptoService,
|
||||
AuthStateProvider authStateProvider)
|
||||
IAuthStateProvider authStateProvider)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_cryptoService = cryptoService;
|
||||
|
||||
@@ -9,12 +9,13 @@ namespace JdeScoping.Client.Services;
|
||||
/// Encrypts login credentials using Web Crypto API via JavaScript interop.
|
||||
/// Uses RSA-OAEP with SHA-256 to encrypt credentials before transmission.
|
||||
/// </summary>
|
||||
public class CryptoService : ICryptoService
|
||||
public class CryptoService : ICryptoService, IAsyncDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private string? _cachedPublicKeyPem;
|
||||
private readonly SemaphoreSlim _keyLock = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
public CryptoService(HttpClient httpClient, IJSRuntime jsRuntime)
|
||||
{
|
||||
@@ -40,6 +41,8 @@ public class CryptoService : ICryptoService
|
||||
|
||||
private async Task<string> GetOrFetchPublicKeyAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_cachedPublicKeyPem is not null)
|
||||
return _cachedPublicKeyPem;
|
||||
|
||||
@@ -60,4 +63,18 @@ public class CryptoService : ICryptoService
|
||||
_keyLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the semaphore used for thread-safe key caching.
|
||||
/// </summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_keyLock.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using JdeScoping.Client.Models;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling search submission operations.
|
||||
/// </summary>
|
||||
public interface ISearchSubmissionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a search and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="search">The search view model to submit.</param>
|
||||
/// <returns>The submission result containing the search ID on success, or error information on failure.</returns>
|
||||
Task<SearchSubmissionResult> SubmitAsync(SearchViewModel search);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a search submission operation.
|
||||
/// </summary>
|
||||
public class SearchSubmissionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the search ID if submission was successful.
|
||||
/// </summary>
|
||||
public int? SearchId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if submission failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the submission was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess => SearchId.HasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static SearchSubmissionResult Success(int searchId) => new() { SearchId = searchId };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result.
|
||||
/// </summary>
|
||||
public static SearchSubmissionResult Failure(string error) => new() { ErrorMessage = error };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using JdeScoping.Client.Components.Search;
|
||||
using JdeScoping.Client.Models;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating search criteria before submission.
|
||||
/// </summary>
|
||||
public interface ISearchValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a search is ready for submission.
|
||||
/// </summary>
|
||||
/// <param name="search">The search view model to validate.</param>
|
||||
/// <param name="selectedSearchType">The selected search type ID.</param>
|
||||
/// <param name="visibilityManager">The filter visibility manager with current filter state.</param>
|
||||
/// <returns>A validation error message, or null if valid.</returns>
|
||||
string? Validate(SearchViewModel search, int? selectedSearchType, FilterVisibilityManager visibilityManager);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using JdeScoping.Client.Extensions;
|
||||
using JdeScoping.Client.Models;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling search submission operations.
|
||||
/// </summary>
|
||||
public class SearchSubmissionService : ISearchSubmissionService
|
||||
{
|
||||
private readonly ISearchApiClient _searchApi;
|
||||
|
||||
public SearchSubmissionService(ISearchApiClient searchApi)
|
||||
{
|
||||
_searchApi = searchApi;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SearchSubmissionResult> SubmitAsync(SearchViewModel search)
|
||||
{
|
||||
var result = await _searchApi.CreateSearchAsync(search.ToCore());
|
||||
|
||||
SearchSubmissionResult? submissionResult = null;
|
||||
|
||||
result.Switch(
|
||||
id => { submissionResult = SearchSubmissionResult.Success(id); },
|
||||
notFound => { submissionResult = SearchSubmissionResult.Failure("Search not found."); },
|
||||
validation => { submissionResult = SearchSubmissionResult.Failure(FormatValidationErrors(validation.FieldErrors)); },
|
||||
unauthorized => { submissionResult = SearchSubmissionResult.Failure("Session expired."); },
|
||||
forbidden => { submissionResult = SearchSubmissionResult.Failure("Access denied."); },
|
||||
error => { submissionResult = SearchSubmissionResult.Failure(error.Message); }
|
||||
);
|
||||
|
||||
return submissionResult ?? SearchSubmissionResult.Failure("Unknown error occurred.");
|
||||
}
|
||||
|
||||
private static string FormatValidationErrors(IReadOnlyDictionary<string, string[]> fieldErrors)
|
||||
{
|
||||
var messages = fieldErrors.SelectMany(kv => kv.Value);
|
||||
return string.Join(" ", messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using JdeScoping.Client.Components.Search;
|
||||
using JdeScoping.Client.Models;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating search criteria before submission.
|
||||
/// </summary>
|
||||
public class SearchValidationService : ISearchValidationService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? Validate(SearchViewModel search, int? selectedSearchType, FilterVisibilityManager visibilityManager)
|
||||
{
|
||||
// Validate name
|
||||
if (string.IsNullOrWhiteSpace(search.Name))
|
||||
{
|
||||
return "Name is required.";
|
||||
}
|
||||
|
||||
// Validate search type
|
||||
if (selectedSearchType == null)
|
||||
{
|
||||
return "Search type is required.";
|
||||
}
|
||||
|
||||
// Validate filters based on search type
|
||||
return visibilityManager.ValidateFilters();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
@using JdeScoping.Client.Components.Admin
|
||||
@using JdeScoping.Client.Components.FilterPanels
|
||||
@using JdeScoping.Client.Extensions
|
||||
@using JdeScoping.Client.Components.Search
|
||||
@using JdeScoping.Client.Components.Shared
|
||||
@using JdeScoping.Client.Layout
|
||||
@using JdeScoping.Client.Models
|
||||
|
||||
@@ -13,6 +13,14 @@ window.downloadFile = function (fileName, byteArray) {
|
||||
};
|
||||
|
||||
window.jdeScopingInterop = {
|
||||
// Programmatically click an element by its ID (used for triggering file inputs)
|
||||
clickElementById: function (elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
},
|
||||
|
||||
// Download file from a byte array stream
|
||||
downloadFileFromStream: async function (fileName, contentStreamReference) {
|
||||
const arrayBuffer = await contentStreamReference.arrayBuffer();
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
using JdeScoping.Core.Models;
|
||||
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication service interface
|
||||
/// </summary>
|
||||
public interface IAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates a user with the given credentials
|
||||
/// </summary>
|
||||
/// <param name="username">Username</param>
|
||||
/// <param name="password">Password</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Authentication result</returns>
|
||||
Task<AuthResult> AuthenticateAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets user information for the given username
|
||||
/// </summary>
|
||||
/// <param name="username">Username to lookup</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>User info if found, null otherwise</returns>
|
||||
Task<UserInfo?> GetUserInfoAsync(
|
||||
string username,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user is a member of a specific group
|
||||
/// </summary>
|
||||
/// <param name="username">Username to check</param>
|
||||
/// <param name="groupName">Group name or DN to check membership</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>True if user is in the group, false otherwise</returns>
|
||||
Task<bool> IsInGroupAsync(
|
||||
string username,
|
||||
string groupName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using JdeScoping.Core.Models;
|
||||
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Core authentication service interface.
|
||||
/// Provides credential-based user authentication.
|
||||
/// </summary>
|
||||
public interface IAuthenticationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates a user with the given credentials.
|
||||
/// </summary>
|
||||
/// <param name="username">Username</param>
|
||||
/// <param name="password">Password</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Authentication result containing success status and user info if successful</returns>
|
||||
Task<AuthResult> AuthenticateAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using JdeScoping.Core.Models.SearchResults;
|
||||
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,5 +13,5 @@ public interface IExcelExportService
|
||||
/// <param name="search">Search model with criteria and results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Excel file as byte array.</returns>
|
||||
Task<byte[]> GenerateAsync(object search, CancellationToken cancellationToken = default);
|
||||
Task<byte[]> GenerateAsync(SearchModel search, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace JdeScoping.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for securely storing and retrieving encrypted secrets.
|
||||
/// </summary>
|
||||
public interface ISecureStoreService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a secret value by key, returning null if not found.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The secret value, or null if not found.</returns>
|
||||
string? Get(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a required secret value by key, throwing if not found.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The secret value.</returns>
|
||||
/// <exception cref="KeyNotFoundException">Thrown when the key is not found.</exception>
|
||||
string GetRequired(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a secret value.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
void Set(string key, string value);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a secret exists.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>True if the secret exists, false otherwise.</returns>
|
||||
bool Contains(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a secret by key.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>True if the secret was removed, false if it didn't exist.</returns>
|
||||
bool Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Persists any pending changes to the store.
|
||||
/// </summary>
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all secret keys in the store.
|
||||
/// </summary>
|
||||
IEnumerable<string> Keys { get; }
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<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.Logging.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" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.Core.Models.Search;
|
||||
|
||||
@@ -58,17 +59,8 @@ public class Search
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(CriteriaJson))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SearchCriteria>(CriteriaJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
TryGetCriteria(out var criteria);
|
||||
return criteria;
|
||||
}
|
||||
set
|
||||
{
|
||||
@@ -78,6 +70,31 @@ public class Search
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to deserialize the search criteria from JSON.
|
||||
/// </summary>
|
||||
/// <param name="criteria">The deserialized criteria, or null if deserialization fails or JSON is empty.</param>
|
||||
/// <param name="logger">Optional logger for warning on deserialization failures.</param>
|
||||
/// <returns>True if deserialization succeeded or JSON was empty; false if deserialization failed.</returns>
|
||||
public bool TryGetCriteria(out SearchCriteria? criteria, ILogger? logger = null)
|
||||
{
|
||||
criteria = null;
|
||||
|
||||
if (string.IsNullOrEmpty(CriteriaJson))
|
||||
return true; // Empty is valid, not an error
|
||||
|
||||
try
|
||||
{
|
||||
criteria = JsonSerializer.Deserialize<SearchCriteria>(CriteriaJson);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Failed to deserialize search criteria for Search ID {SearchId}", Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excel search results file (binary)
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace JdeScoping.Core.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the secure secrets store.
|
||||
/// </summary>
|
||||
public class SecureStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name in appsettings.json.
|
||||
/// </summary>
|
||||
public const string SectionName = "SecureStore";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the encrypted secrets store file.
|
||||
/// Defaults to "data/secrets.json" relative to app directory.
|
||||
/// </summary>
|
||||
public string StorePath { get; set; } = "data/secrets.json";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the key file (used in development).
|
||||
/// Defaults to "data/secrets.key" relative to app directory.
|
||||
/// </summary>
|
||||
public string KeyFilePath { get; set; } = "data/secrets.key";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name containing the master key (used in production).
|
||||
/// If set and the env var exists, it takes precedence over the key file.
|
||||
/// </summary>
|
||||
public string MasterKeyEnvVar { get; set; } = "SCOPINGTOOL_MASTER_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to auto-create the store and generate a key file on first run.
|
||||
/// </summary>
|
||||
public bool AutoCreateStore { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to migrate existing secrets (RSA key, Excel passwords) on startup.
|
||||
/// </summary>
|
||||
public bool MigrateExistingSecrets { get; set; } = true;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace JdeScoping.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// View model for component lot filter.
|
||||
/// View model for component lot filter with value-based equality semantics.
|
||||
/// Implements <see cref="Equals"/> and <see cref="GetHashCode"/> to support
|
||||
/// deduplication in collections, LINQ Distinct(), and Contains() checks.
|
||||
/// For simple display scenarios without collection operations, use <see cref="LotViewModel"/>.
|
||||
/// </summary>
|
||||
public class ComponentLotViewModel
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace JdeScoping.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// View model for lot projection
|
||||
/// View model for simple lot projection without equality semantics.
|
||||
/// Use this for read-only display and data transfer where collection membership
|
||||
/// checks are not needed. For collection operations requiring deduplication or
|
||||
/// Contains() checks, use <see cref="ComponentLotViewModel"/> instead.
|
||||
/// </summary>
|
||||
public class LotViewModel
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace JdeScoping.Core.ViewModels;
|
||||
/// </summary>
|
||||
public class OperatorViewModel
|
||||
{
|
||||
public int AddressNumber { get; set; }
|
||||
public long AddressNumber { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -62,9 +62,10 @@ public class SearchViewModel
|
||||
/// Constructor that copies values from a Search entity
|
||||
/// </summary>
|
||||
/// <param name="search">Search to copy values from</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when search is null</exception>
|
||||
public SearchViewModel(Search search)
|
||||
{
|
||||
if (search == null) return;
|
||||
ArgumentNullException.ThrowIfNull(search);
|
||||
|
||||
Id = search.Id;
|
||||
UserName = search.UserName;
|
||||
|
||||
@@ -42,10 +42,11 @@ public static class DataAccessDependencyInjection
|
||||
// Register SqlKata compiler (singleton, thread-safe)
|
||||
services.AddSingleton<SqlServerCompiler>();
|
||||
|
||||
// Register query builder (scoped)
|
||||
// Register query builders (scoped)
|
||||
// Note: Filter criteria are extracted from database JSON using SQL functions,
|
||||
// eliminating the need for filter handler classes.
|
||||
services.AddScoped<ISearchQueryBuilder, SqlKataSearchQueryBuilder>();
|
||||
services.AddScoped<IMisQueryBuilder, MisQueryBuilder>();
|
||||
|
||||
// Register search processing services (scoped)
|
||||
services.AddScoped<IWorkOrderTraversalService, WorkOrderTraversalService>();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace JdeScoping.DataAccess.QueryBuilders;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for building MIS (Manufacturing Information System) extraction queries.
|
||||
/// </summary>
|
||||
public interface IMisQueryBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete MIS extraction SQL including temp table setup and data population.
|
||||
/// Uses extraction functions to get filter criteria from the database.
|
||||
/// </summary>
|
||||
/// <param name="searchId">The search ID to extract criteria from.</param>
|
||||
/// <returns>The SQL statements for MIS extraction.</returns>
|
||||
IReadOnlyList<string> BuildMisExtractionSql(int searchId);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace JdeScoping.DataAccess.QueryBuilders;
|
||||
/// Builds MIS extraction queries for work order step matching.
|
||||
/// Uses SQL extraction functions to retrieve criteria from Search.Criteria JSON.
|
||||
/// </summary>
|
||||
public sealed class MisQueryBuilder
|
||||
public sealed class MisQueryBuilder : IMisQueryBuilder
|
||||
{
|
||||
private readonly SqlServerCompiler _compiler;
|
||||
|
||||
|
||||
@@ -12,23 +12,12 @@ public partial class LotFinderRepository
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<DataUpdate>> GetLastDataUpdatesAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetLastDataUpdatesAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<DataUpdate>(
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetLastDataUpdatesAsync),
|
||||
"SQL_GET_LAST_DATA_UPDATES",
|
||||
async connection => (await connection.QueryAsync<DataUpdate>(
|
||||
LotFinderQueries.SqlGetLastDataUpdates,
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_LAST_DATA_UPDATES");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,175 +16,112 @@ public partial class LotFinderRepository
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Item>> SearchItemsAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchItemsAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Item>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchItemsAsync),
|
||||
"SQL_SEARCH_ITEMS",
|
||||
async connection => (await connection.QueryAsync<Item>(
|
||||
LotFinderQueries.SqlSearchItems,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_ITEMS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Item>> LookupItemsAsync(List<string> itemNumbers, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(LookupItemsAsync);
|
||||
try
|
||||
{
|
||||
var itemNumbersCsv = string.Join(",", itemNumbers);
|
||||
ArgumentNullException.ThrowIfNull(itemNumbers);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Item>(
|
||||
var itemNumbersCsv = string.Join(",", itemNumbers);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(LookupItemsAsync),
|
||||
"SQL_LOOKUP_ITEMS",
|
||||
async connection => (await connection.QueryAsync<Item>(
|
||||
LotFinderQueries.SqlLookupItems,
|
||||
new { itemNumbers = itemNumbersCsv },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_LOOKUP_ITEMS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<WorkOrder>> LookupWorkordersAsync(List<long> workorderNumbers, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(LookupWorkordersAsync);
|
||||
try
|
||||
{
|
||||
var workOrderNumbersCsv = string.Join(",", workorderNumbers);
|
||||
ArgumentNullException.ThrowIfNull(workorderNumbers);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<WorkOrder>(
|
||||
var workOrderNumbersCsv = string.Join(",", workorderNumbers);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(LookupWorkordersAsync),
|
||||
"SQL_LOOKUP_WORKORDERS",
|
||||
async connection => (await connection.QueryAsync<WorkOrder>(
|
||||
LotFinderQueries.SqlLookupWorkorders,
|
||||
new { workOrderNumbers = workOrderNumbersCsv },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_LOOKUP_WORKORDERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<WorkCenter>> SearchWorkCentersAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchWorkCentersAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<WorkCenter>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchWorkCentersAsync),
|
||||
"SQL_SEARCH_WORK_CENTERS",
|
||||
async connection => (await connection.QueryAsync<WorkCenter>(
|
||||
LotFinderQueries.SqlSearchWorkCenters,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_WORK_CENTERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<ProfitCenter>> SearchProfitCentersAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchProfitCentersAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<ProfitCenter>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchProfitCentersAsync),
|
||||
"SQL_SEARCH_PROFIT_CENTERS",
|
||||
async connection => (await connection.QueryAsync<ProfitCenter>(
|
||||
LotFinderQueries.SqlSearchProfitCenters,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_PROFIT_CENTERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JdeUser>> SearchUsersAsync(string filter, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SearchUsersAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<JdeUser>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SearchUsersAsync),
|
||||
"SQL_SEARCH_USERS",
|
||||
async connection => (await connection.QueryAsync<JdeUser>(
|
||||
LotFinderQueries.SqlSearchUsers,
|
||||
new { filter },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_SEARCH_USERS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Lot>> LookupLotsAsync(List<LotViewModel> lots, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(LookupLotsAsync);
|
||||
try
|
||||
{
|
||||
var lotsJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
lots.Select(l => new { l.LotNumber, l.ItemNumber }));
|
||||
ArgumentNullException.ThrowIfNull(lots);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Lot>(
|
||||
var lotsJson = System.Text.Json.JsonSerializer.Serialize(
|
||||
lots.Select(l => new { l.LotNumber, l.ItemNumber }));
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(LookupLotsAsync),
|
||||
"SQL_LOOKUP_LOTS",
|
||||
async connection => (await connection.QueryAsync<Lot>(
|
||||
LotFinderQueries.SqlLookupLots,
|
||||
new { lotsJson },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_LOOKUP_LOTS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
+61
-103
@@ -15,141 +15,99 @@ public partial class LotFinderRepository
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Search>> GetUserSearchesAsync(string userName, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetUserSearchesAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Search>(
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetUserSearchesAsync),
|
||||
"SQL_GET_USER_SEARCHES",
|
||||
async connection => (await connection.QueryAsync<Search>(
|
||||
LotFinderQueries.SqlGetUserSearches,
|
||||
new { userName },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_USER_SEARCHES");
|
||||
throw; // Unreachable but satisfies compiler
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Search>> GetQueuedSearchesAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetQueuedSearchesAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryAsync<Search>(
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetQueuedSearchesAsync),
|
||||
"SQL_GET_QUEUED_SEARCHES",
|
||||
async connection => (await connection.QueryAsync<Search>(
|
||||
LotFinderQueries.SqlGetQueuedSearches,
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
return result.ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_QUEUED_SEARCHES");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Search?> GetSearchAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetSearchAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
var result = await connection.QueryFirstOrDefaultAsync<Search>(
|
||||
LotFinderQueries.SqlGetSearch,
|
||||
new { id },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
|
||||
if (result != null)
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetSearchAsync),
|
||||
"SQL_GET_SEARCH",
|
||||
async connection =>
|
||||
{
|
||||
result.Id = id;
|
||||
}
|
||||
var result = await connection.QueryFirstOrDefaultAsync<Search>(
|
||||
LotFinderQueries.SqlGetSearch,
|
||||
new { id },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_SEARCH");
|
||||
throw;
|
||||
}
|
||||
if (result != null)
|
||||
{
|
||||
result.Id = id;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<byte[]?> GetSearchResultsAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(GetSearchResultsAsync);
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
return await connection.QueryFirstOrDefaultAsync<byte[]>(
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(GetSearchResultsAsync),
|
||||
"SQL_GET_SEARCH_RESULTS",
|
||||
connection => connection.QueryFirstOrDefaultAsync<byte[]>(
|
||||
LotFinderQueries.SqlGetSearchResults,
|
||||
new { id },
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, "SQL_GET_SEARCH_RESULTS");
|
||||
throw;
|
||||
}
|
||||
commandTimeout: _options.Value.DefaultTimeoutSeconds),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> SubmitSearchAsync(Search search, CancellationToken ct = default)
|
||||
{
|
||||
const string operation = nameof(SubmitSearchAsync);
|
||||
try
|
||||
{
|
||||
search.Status = SearchStatus.Queued;
|
||||
search.SubmitDt = DateTime.UtcNow;
|
||||
ArgumentNullException.ThrowIfNull(search);
|
||||
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
await using var command = new SqlCommand(SqlObjects.SubmitSearch, connection)
|
||||
search.Status = SearchStatus.Queued;
|
||||
search.SubmitDt = DateTime.UtcNow;
|
||||
|
||||
return await ExecuteQueryAsync(
|
||||
nameof(SubmitSearchAsync),
|
||||
SqlObjects.SubmitSearch,
|
||||
async connection =>
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = _options.Value.DefaultTimeoutSeconds
|
||||
};
|
||||
await using var command = new SqlCommand(SqlObjects.SubmitSearch, connection)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = _options.Value.DefaultTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("p_UserName", search.UserName);
|
||||
command.Parameters.AddWithValue("p_Name", search.Name);
|
||||
command.Parameters.AddWithValue("p_Criteria", search.CriteriaJson);
|
||||
command.Parameters.AddWithValue("p_UserName", search.UserName);
|
||||
command.Parameters.AddWithValue("p_Name", search.Name);
|
||||
command.Parameters.AddWithValue("p_Criteria", search.CriteriaJson);
|
||||
|
||||
var searchIdParam = new SqlParameter("o_SearchID", SqlDbType.Int)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
command.Parameters.Add(searchIdParam);
|
||||
var searchIdParam = new SqlParameter("o_SearchID", SqlDbType.Int)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
command.Parameters.Add(searchIdParam);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct);
|
||||
return Convert.ToInt32(searchIdParam.Value);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, SqlObjects.SubmitSearch);
|
||||
throw;
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(ct);
|
||||
return Convert.ToInt32(searchIdParam.Value);
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -66,4 +66,35 @@ public partial class LotFinderRepository : ILotFinderRepository
|
||||
// SQL Server timeout error number: -2
|
||||
return ex.Number == -2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a database query with standard error handling.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The return type of the query.</typeparam>
|
||||
/// <param name="operation">The name of the calling operation for logging.</param>
|
||||
/// <param name="queryName">The name of the query for logging.</param>
|
||||
/// <param name="queryAction">The async function that executes the query.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the query action.</returns>
|
||||
private async Task<T> ExecuteQueryAsync<T>(
|
||||
string operation,
|
||||
string queryName,
|
||||
Func<SqlConnection, Task<T>> queryAction,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
|
||||
return await queryAction(connection);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogAndThrow(ex, operation, queryName);
|
||||
throw; // Unreachable but satisfies compiler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using JdeScoping.DataAccess.QueryBuilders;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SqlKata.Compilers;
|
||||
|
||||
namespace JdeScoping.DataAccess.Services;
|
||||
|
||||
@@ -21,7 +20,7 @@ public sealed class SearchProcessor : ISearchProcessor
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly ISearchQueryBuilder _queryBuilder;
|
||||
private readonly IWorkOrderTraversalService _traversalService;
|
||||
private readonly MisQueryBuilder _misQueryBuilder;
|
||||
private readonly IMisQueryBuilder _misQueryBuilder;
|
||||
private readonly SearchProcessingConfiguration _options;
|
||||
private readonly ILogger<SearchProcessor> _logger;
|
||||
|
||||
@@ -32,14 +31,14 @@ public sealed class SearchProcessor : ISearchProcessor
|
||||
IDbConnectionFactory connectionFactory,
|
||||
ISearchQueryBuilder queryBuilder,
|
||||
IWorkOrderTraversalService traversalService,
|
||||
SqlServerCompiler compiler,
|
||||
IMisQueryBuilder misQueryBuilder,
|
||||
IOptions<SearchProcessingConfiguration> options,
|
||||
ILogger<SearchProcessor> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_queryBuilder = queryBuilder;
|
||||
_traversalService = traversalService;
|
||||
_misQueryBuilder = new MisQueryBuilder(compiler);
|
||||
_misQueryBuilder = misQueryBuilder;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,11 @@ public class DevEtlRegistry
|
||||
using var semaphore = new SemaphoreSlim(maxDegreeOfParallelism);
|
||||
|
||||
// Separate tables by size - run very large ones sequentially at the end
|
||||
var smallMediumTables = GetAvailableTables()
|
||||
var allTables = GetAvailableTables().ToList();
|
||||
var smallMediumTables = allTables
|
||||
.Where(t => !_pipelineFactory.IsVeryLargeTable(t))
|
||||
.ToList();
|
||||
var veryLargeTables = GetAvailableTables()
|
||||
var veryLargeTables = allTables
|
||||
.Where(t => _pipelineFactory.IsVeryLargeTable(t))
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# JdeScoping.DataSync.Dev
|
||||
|
||||
Development-only ETL tooling for loading cached protobuf data into SQL Server. This project enables developers to work with production-like data locally without connecting to live JDE/CMS systems.
|
||||
|
||||
## Purpose
|
||||
|
||||
This project provides a way to load pre-cached data snapshots (in protobuf format with zstd compression) into the local SQL Server database. It is intended **only for development and testing** - production data sync uses the `JdeScoping.DataSync` project with live connections to enterprise systems.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Cache Directory**: A folder containing protobuf data files (`.pb.zstd` format)
|
||||
2. **SQL Server Database**: Local SQL Server instance with the JDE Scoping schema
|
||||
3. **Connection String**: Valid SQL Server connection configured in `appsettings.json`
|
||||
|
||||
## Configuration
|
||||
|
||||
Pipeline configurations are stored in `Pipelines/dev-pipelines.json`. This file defines:
|
||||
|
||||
- **Size categories**: Tables are categorized as small, medium, large, or veryLarge
|
||||
- **Pipeline definitions**: Source file mappings to destination tables
|
||||
|
||||
### Size Categories
|
||||
|
||||
| Category | Tables | Parallelization |
|
||||
|----------|--------|-----------------|
|
||||
| Small | Branch, OrgHierarchy, WorkCenter, ProfitCenter | Parallel |
|
||||
| Medium | JdeUser, FunctionCode, Item, RouteMaster, MisData_Curr | Parallel |
|
||||
| Large | Lot, MisData_Hist, WorkOrder_Curr/Hist, LotUsage_Hist, WorkOrderComponent_Hist | Parallel |
|
||||
| VeryLarge | WorkOrderStep_*, WorkOrderComponent_Curr, WorkOrderRouting, LotUsage_Curr, WorkOrderTime_* | Sequential |
|
||||
|
||||
Very large tables run sequentially at the end to avoid I/O contention.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
JdeScoping.DataSync.Dev/
|
||||
├── Configuration/ # DTOs for JSON config deserialization
|
||||
├── Contracts/ # Interface definitions (IDevEtlPipelineFactory)
|
||||
├── Options/ # Options pattern classes
|
||||
├── Services/ # Implementation (DevEtlPipelineFactory)
|
||||
├── Sources/ # IImportSource implementations (ProtobufZstdFileSource)
|
||||
├── Pipelines/ # JSON configuration files
|
||||
└── DevEtlRegistry.cs # Main orchestrator class
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```csharp
|
||||
// Create the registry
|
||||
var factory = new DevEtlPipelineFactory(options, connectionString, logger);
|
||||
var registry = new DevEtlRegistry(factory, cacheDirectory, logger);
|
||||
|
||||
// List available tables
|
||||
foreach (var table in registry.GetAvailableTables())
|
||||
{
|
||||
Console.WriteLine(table);
|
||||
}
|
||||
```
|
||||
|
||||
### Run Single Table
|
||||
|
||||
```csharp
|
||||
var result = await registry.RunAsync("Branch");
|
||||
if (result.Success)
|
||||
{
|
||||
Console.WriteLine($"Loaded {result.TotalRows} rows in {result.Elapsed}");
|
||||
}
|
||||
```
|
||||
|
||||
### Run All Tables Sequentially
|
||||
|
||||
```csharp
|
||||
var results = await registry.RunAllAsync(cancellationToken);
|
||||
```
|
||||
|
||||
### Run All Tables with Parallelization
|
||||
|
||||
```csharp
|
||||
// Run small/medium/large tables in parallel (max 4 concurrent)
|
||||
// Very large tables run sequentially at the end
|
||||
var results = await registry.RunAllParallelAsync(
|
||||
maxDegreeOfParallelism: 4,
|
||||
cancellationToken);
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Source**: `ProtobufZstdFileSource` reads `.pb.zstd` files using protobuf-net-data
|
||||
2. **Transform**: Data passes through as `IDataReader` (no transformation)
|
||||
3. **Destination**: Uses `JdeScoping.DataSync` bulk import/merge destinations
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **JdeScoping.DataSync**: Core ETL pipeline infrastructure
|
||||
- **protobuf-net-data**: Protobuf serialization with IDataReader support
|
||||
|
||||
## Testing
|
||||
|
||||
The project supports unit testing via `InternalsVisibleTo`:
|
||||
- `JdeScoping.DataSync.Dev.Tests`
|
||||
- `DynamicProxyGenAssembly2` (for Moq)
|
||||
@@ -9,6 +9,12 @@ namespace JdeScoping.DataSync.Dev.Sources;
|
||||
/// Import source that reads from a zstd-compressed protobuf file.
|
||||
/// Uses protobuf-net-data for IDataReader deserialization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This source wraps the synchronous <c>DataSerializer.Deserialize</c> in <c>Task.Run</c>
|
||||
/// because protobuf-net-data does not provide a native async API. The file is opened with
|
||||
/// <c>FileOptions.Asynchronous</c> to optimize for async I/O patterns, and the synchronous
|
||||
/// deserialization is offloaded to the thread pool to prevent blocking the calling context.
|
||||
/// </remarks>
|
||||
public sealed class ProtobufZstdFileSource : IImportSource
|
||||
{
|
||||
private const int FileBufferSize = 256 * 1024; // 256 KB
|
||||
@@ -33,28 +39,32 @@ public sealed class ProtobufZstdFileSource : IImportSource
|
||||
_filePath = filePath;
|
||||
}
|
||||
|
||||
public Task<IDataReader> ReadDataAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<IDataReader> ReadDataAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fileStream != null)
|
||||
throw new InvalidOperationException("ReadDataAsync has already been called. Dispose and create a new source to read again.");
|
||||
|
||||
try
|
||||
{
|
||||
// Use FileOptions.Asynchronous for optimized async I/O patterns
|
||||
_fileStream = new FileStream(
|
||||
_filePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: FileBufferSize,
|
||||
FileOptions.SequentialScan);
|
||||
FileOptions.SequentialScan | FileOptions.Asynchronous);
|
||||
|
||||
_decompressionStream = new DecompressionStream(_fileStream);
|
||||
_bufferedStream = new BufferedStream(_decompressionStream, DecompressBufferSize);
|
||||
|
||||
// protobuf-net-data returns IDataReader directly
|
||||
_reader = DataSerializer.Deserialize(_bufferedStream);
|
||||
// Offload synchronous deserialization to thread pool since protobuf-net-data
|
||||
// doesn't have a native async API. This prevents blocking the calling context.
|
||||
_reader = await Task.Run(
|
||||
() => DataSerializer.Deserialize(_bufferedStream),
|
||||
cancellationToken);
|
||||
|
||||
return Task.FromResult(_reader);
|
||||
return _reader;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Dapper;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataSync.Etl.Contracts;
|
||||
using JdeScoping.DataSync.Etl.Results;
|
||||
@@ -13,7 +12,7 @@ namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
/// Imports data into a SQL Server table using bulk copy operations.
|
||||
/// Performs a full table refresh by truncating the table before loading.
|
||||
/// </summary>
|
||||
public class DbBulkImportDestination : IImportDestination
|
||||
public class DbBulkImportDestination : DbDestinationBase, IImportDestination
|
||||
{
|
||||
private const int DefaultBatchSize = 100000;
|
||||
private const int DefaultCommandTimeoutSeconds = 600;
|
||||
@@ -22,9 +21,7 @@ public class DbBulkImportDestination : IImportDestination
|
||||
public const int InfiniteTimeout = 0;
|
||||
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly string _tableName;
|
||||
private readonly int _batchSize;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DestinationName => $"BulkImport:{_tableName}";
|
||||
@@ -41,14 +38,13 @@ public class DbBulkImportDestination : IImportDestination
|
||||
string tableName,
|
||||
int batchSize = 0,
|
||||
int commandTimeoutSeconds = 0)
|
||||
: base(tableName, commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connectionFactory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
|
||||
_connectionFactory = connectionFactory;
|
||||
_tableName = tableName;
|
||||
_batchSize = batchSize > 0 ? batchSize : DefaultBatchSize;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -121,17 +117,4 @@ public class DbBulkImportDestination : IImportDestination
|
||||
stopwatch.Stop();
|
||||
return new DestinationResult(totalRows, batchCount, stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetDestinationColumnsAsync(
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (schema, table) = CommonScripts.ParseTableName(_tableName);
|
||||
var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName";
|
||||
var columns = await connection.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { tableName = table, schemaName = schema },
|
||||
commandTimeout: _commandTimeoutSeconds, cancellationToken: ct));
|
||||
return columns.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataSync.Etl.Contracts;
|
||||
using JdeScoping.DataSync.Etl.Results;
|
||||
using JdeScoping.DataSync.Etl.Scripts;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
|
||||
@@ -15,19 +15,18 @@ namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
/// This approach supports incremental updates by matching on key columns and updating
|
||||
/// existing rows or inserting new ones.
|
||||
/// </summary>
|
||||
public class DbBulkMergeDestination : IImportDestination
|
||||
public class DbBulkMergeDestination : DbDestinationBase, IImportDestination
|
||||
{
|
||||
private const int DefaultBatchSize = 10000;
|
||||
private const int DefaultCommandTimeoutSeconds = 600;
|
||||
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly string _tableName;
|
||||
private readonly ILogger<DbBulkMergeDestination>? _logger;
|
||||
private readonly string[] _matchColumns;
|
||||
private readonly string[]? _updateColumns;
|
||||
private readonly string[]? _excludeFromUpdate;
|
||||
private readonly string? _updateCondition;
|
||||
private readonly int _batchSize;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DestinationName => $"BulkMerge:{_tableName}";
|
||||
@@ -43,6 +42,7 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
/// <param name="updateCondition">Optional SQL condition to add to the WHEN MATCHED clause (e.g., "source.LastUpdate > target.LastUpdate").</param>
|
||||
/// <param name="batchSize">Number of rows per batch. 0 uses the default (10000).</param>
|
||||
/// <param name="commandTimeoutSeconds">Command timeout in seconds. 0 uses the default (600).</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public DbBulkMergeDestination(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
string tableName,
|
||||
@@ -51,7 +51,9 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
string[]? excludeFromUpdate = null,
|
||||
string? updateCondition = null,
|
||||
int batchSize = 0,
|
||||
int commandTimeoutSeconds = 0)
|
||||
int commandTimeoutSeconds = 0,
|
||||
ILogger<DbBulkMergeDestination>? logger = null)
|
||||
: base(tableName, commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connectionFactory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
@@ -60,13 +62,12 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
throw new ArgumentException("At least one match column is required.", nameof(matchColumns));
|
||||
|
||||
_connectionFactory = connectionFactory;
|
||||
_tableName = tableName;
|
||||
_logger = logger;
|
||||
_matchColumns = matchColumns;
|
||||
_updateColumns = updateColumns;
|
||||
_excludeFromUpdate = excludeFromUpdate;
|
||||
_updateCondition = updateCondition;
|
||||
_batchSize = batchSize > 0 ? batchSize : DefaultBatchSize;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds > 0 ? commandTimeoutSeconds : DefaultCommandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -177,9 +178,9 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
cmd.CommandTimeout = _commandTimeoutSeconds;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
_logger?.LogDebug(ex, "Failed to drop temporary table {TempTableName} during cleanup", tempTableName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,17 +261,4 @@ public class DbBulkMergeDestination : IImportDestination
|
||||
table.Columns.Add(source.GetName(i), baseType);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetDestinationColumnsAsync(
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (schema, table) = CommonScripts.ParseTableName(_tableName);
|
||||
var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName";
|
||||
var columns = await connection.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { tableName = table, schemaName = schema },
|
||||
commandTimeout: _commandTimeoutSeconds, cancellationToken: ct));
|
||||
return columns.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Dapper;
|
||||
using JdeScoping.DataSync.Etl.Scripts;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace JdeScoping.DataSync.Etl.Destinations;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SQL Server database destinations providing common functionality
|
||||
/// for table metadata operations.
|
||||
/// </summary>
|
||||
public abstract class DbDestinationBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the destination table.
|
||||
/// </summary>
|
||||
protected readonly string _tableName;
|
||||
|
||||
/// <summary>
|
||||
/// The command timeout in seconds for database operations.
|
||||
/// </summary>
|
||||
protected readonly int _commandTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the DbDestinationBase class.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The name of the destination table.</param>
|
||||
/// <param name="commandTimeoutSeconds">The command timeout in seconds.</param>
|
||||
protected DbDestinationBase(string tableName, int commandTimeoutSeconds)
|
||||
{
|
||||
_tableName = tableName;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the column names from the destination table.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQL Server connection to use.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A HashSet containing the column names (case-insensitive).</returns>
|
||||
protected async Task<HashSet<string>> GetDestinationColumnsAsync(
|
||||
SqlConnection connection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (schema, table) = CommonScripts.ParseTableName(_tableName);
|
||||
var sql = @"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = @tableName AND TABLE_SCHEMA = @schemaName";
|
||||
var columns = await connection.QueryAsync<string>(
|
||||
new CommandDefinition(sql, new { tableName = table, schemaName = schema },
|
||||
commandTimeout: _commandTimeoutSeconds, cancellationToken: ct));
|
||||
return columns.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,25 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace JdeScoping.Database;
|
||||
|
||||
public class DatabaseMigrator
|
||||
/// <summary>
|
||||
/// Handles database migrations using DbUp.
|
||||
/// </summary>
|
||||
public class DatabaseMigrator : IDatabaseMigrator
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the DatabaseMigrator class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Application configuration containing connection strings.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when SqlServer connection string is not configured.</exception>
|
||||
public DatabaseMigrator(IConfiguration configuration)
|
||||
{
|
||||
_connectionString = configuration.GetConnectionString("SqlServer")
|
||||
?? throw new InvalidOperationException("SqlServer connection string not configured");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DatabaseUpgradeResult Migrate()
|
||||
{
|
||||
EnsureDatabase.For.SqlDatabase(_connectionString);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DbUp.Engine;
|
||||
|
||||
namespace JdeScoping.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for database migration operations.
|
||||
/// </summary>
|
||||
public interface IDatabaseMigrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs all pending database migrations.
|
||||
/// </summary>
|
||||
/// <returns>The result of the migration operation.</returns>
|
||||
DatabaseUpgradeResult Migrate();
|
||||
}
|
||||
@@ -8,6 +8,8 @@ using JdeScoping.ExcelIO.Mapping;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// Use Core's SearchModel for the public interface
|
||||
using CoreSearchModel = JdeScoping.Core.Models.SearchResults.SearchModel;
|
||||
// Use ExcelIO's SearchModel which contains criteria filter properties for CriteriaSheetGenerator
|
||||
using ExcelSearchModel = JdeScoping.ExcelIO.Models.Reporting.SearchModel;
|
||||
|
||||
@@ -47,23 +49,42 @@ public class ExcelExportService : IExcelExportService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> GenerateAsync(object search, CancellationToken cancellationToken = default)
|
||||
public async Task<byte[]> GenerateAsync(CoreSearchModel search, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (search is not ExcelSearchModel searchModel)
|
||||
{
|
||||
throw new ArgumentException($"Expected {nameof(ExcelSearchModel)} but received {search.GetType().Name}", nameof(search));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(search);
|
||||
|
||||
return await GenerateAsync(searchModel, cancellationToken);
|
||||
// Map Core SearchModel to ExcelIO SearchModel for internal processing
|
||||
var excelModel = MapToExcelModel(search);
|
||||
return await GenerateInternalAsync(excelModel, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an Excel file from the provided search model.
|
||||
/// Maps Core SearchModel to ExcelIO SearchModel for Excel generation.
|
||||
/// </summary>
|
||||
/// <param name="search">Search model with criteria and results.</param>
|
||||
private static ExcelSearchModel MapToExcelModel(CoreSearchModel source)
|
||||
{
|
||||
return new ExcelSearchModel
|
||||
{
|
||||
Id = source.Id,
|
||||
UserName = source.UserName,
|
||||
Name = source.Name,
|
||||
SubmitDt = source.SubmitDt,
|
||||
StartDt = source.StartDt,
|
||||
EndDt = source.EndDt,
|
||||
ExtractMisData = source.ExtractMisData,
|
||||
Results = source.Results,
|
||||
MisResults = source.MisResults,
|
||||
MisNonMatchResults = source.MisNonMatchResults
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal method that generates an Excel file from the ExcelIO search model.
|
||||
/// </summary>
|
||||
/// <param name="search">ExcelIO search model with criteria and results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Excel file as byte array.</returns>
|
||||
public async Task<byte[]> GenerateAsync(ExcelSearchModel search, CancellationToken cancellationToken = default)
|
||||
private async Task<byte[]> GenerateInternalAsync(ExcelSearchModel search, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
|
||||
@@ -168,13 +168,17 @@ public class CriteriaSheetGenerator
|
||||
WorksheetProtector.ApplyCriteriaProtection(worksheet, _options.Value.CriteriaSheetPassword);
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTime? dateTime)
|
||||
private string FormatTimestamp(DateTime? dateTime)
|
||||
{
|
||||
if (!dateTime.HasValue)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{dateTime.Value:MMM dd, yyyy hh:mm:ss tt} EST";
|
||||
var options = _options.Value;
|
||||
var timezone = TimeZoneInfo.FindSystemTimeZoneById(options.TimezoneId);
|
||||
var localTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime.Value, timezone);
|
||||
|
||||
return $"{localTime:MMM dd, yyyy hh:mm:ss tt} {options.TimezoneAbbreviation}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClosedXML.Excel;
|
||||
using JdeScoping.ExcelIO.Formatting;
|
||||
using JdeScoping.ExcelIO.Utilities;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Generators;
|
||||
|
||||
@@ -31,7 +32,7 @@ public class DataEntryTemplateGenerator
|
||||
var row = 2;
|
||||
foreach (var item in sourceData)
|
||||
{
|
||||
worksheet.Cell(row++, 1).Value = ConvertToXlValue(item);
|
||||
worksheet.Cell(row++, 1).Value = CellValueConverter.ConvertToXlValue(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +70,7 @@ public class DataEntryTemplateGenerator
|
||||
{
|
||||
for (var col = 0; col < sourceData[row].Length; col++)
|
||||
{
|
||||
worksheet.Cell(row + 2, col + 1).Value = ConvertToXlValue(sourceData[row][col]);
|
||||
worksheet.Cell(row + 2, col + 1).Value = CellValueConverter.ConvertToXlValue(sourceData[row][col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,21 +79,4 @@ public class DataEntryTemplateGenerator
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static XLCellValue ConvertToXlValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => Blank.Value,
|
||||
string s => s,
|
||||
int i => i,
|
||||
long l => l,
|
||||
decimal d => d,
|
||||
double dbl => dbl,
|
||||
float f => f,
|
||||
DateTime dt => dt,
|
||||
bool b => b,
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClosedXML.Excel;
|
||||
using JdeScoping.ExcelIO.Formatting;
|
||||
using JdeScoping.ExcelIO.Mapping;
|
||||
using JdeScoping.ExcelIO.Utilities;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Generators;
|
||||
|
||||
@@ -72,7 +73,7 @@ public sealed class FluentTableWriter
|
||||
foreach (var column in columns)
|
||||
{
|
||||
var value = column.ValueGetter(item!);
|
||||
worksheet.Cell(row, col).Value = ConvertToXlValue(value);
|
||||
worksheet.Cell(row, col).Value = CellValueConverter.ConvertToXlValue(value);
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
@@ -125,21 +126,4 @@ public sealed class FluentTableWriter
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static XLCellValue ConvertToXlValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => Blank.Value,
|
||||
string s => s,
|
||||
int i => i,
|
||||
long l => l,
|
||||
decimal d => d,
|
||||
double dbl => dbl,
|
||||
float f => f,
|
||||
DateTime dt => dt,
|
||||
bool b => b,
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ public class ExcelExportOptions
|
||||
|
||||
/// <summary>
|
||||
/// Password for protecting the Search Criteria sheet.
|
||||
/// When empty, the password is retrieved from SecureStore using key "ExcelExport:CriteriaSheetPassword".
|
||||
/// </summary>
|
||||
public string CriteriaSheetPassword { get; set; } = "JDE_SCOPING_TOOL_PASS";
|
||||
public string CriteriaSheetPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Password for protecting data sheets (Results, MIS Info, Investigation).
|
||||
/// When empty, the password is retrieved from SecureStore using key "ExcelExport:DataSheetPassword".
|
||||
/// </summary>
|
||||
public string DataSheetPassword { get; set; } = "JDESCOPINGTOOL";
|
||||
public string DataSheetPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of rows per Excel sheet.
|
||||
@@ -39,4 +41,15 @@ public class ExcelExportOptions
|
||||
/// Directory for debug output files.
|
||||
/// </summary>
|
||||
public string DebugOutputDirectory { get; set; } = "/tmp/lotfinder";
|
||||
|
||||
/// <summary>
|
||||
/// Windows timezone ID for timestamp display in exported files.
|
||||
/// Examples: "Eastern Standard Time", "Pacific Standard Time", "UTC"
|
||||
/// </summary>
|
||||
public string TimezoneId { get; set; } = "Eastern Standard Time";
|
||||
|
||||
/// <summary>
|
||||
/// Abbreviation to display after timestamps (e.g., "EST", "PST", "UTC").
|
||||
/// </summary>
|
||||
public string TimezoneAbbreviation { get; set; } = "EST";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClosedXML.Excel;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Parsing;
|
||||
|
||||
@@ -9,6 +10,13 @@ namespace JdeScoping.ExcelIO.Parsing;
|
||||
/// </summary>
|
||||
public class ExcelParserService : IExcelParserService
|
||||
{
|
||||
private readonly ILogger<ExcelParserService> _logger;
|
||||
|
||||
public ExcelParserService(ILogger<ExcelParserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public List<long> ParseWorkOrders(Stream fileStream)
|
||||
{
|
||||
@@ -113,9 +121,9 @@ public class ExcelParserService : IExcelParserService
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Skip invalid rows
|
||||
_logger.LogWarning(ex, "Failed to parse row {RowNumber} in part operations sheet", row);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace JdeScoping.ExcelIO.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for converting .NET objects to ClosedXML cell values.
|
||||
/// </summary>
|
||||
public static class CellValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a .NET object to an XLCellValue for use in ClosedXML worksheets.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to convert.</param>
|
||||
/// <returns>An XLCellValue suitable for setting as a cell value.</returns>
|
||||
public static XLCellValue ConvertToXlValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => Blank.Value,
|
||||
string s => s,
|
||||
int i => i,
|
||||
long l => l,
|
||||
decimal d => d,
|
||||
double dbl => dbl,
|
||||
float f => f,
|
||||
DateTime dt => dt,
|
||||
bool b => b,
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using JdeScoping.DataAccess.Options;
|
||||
using JdeScoping.DataSync.Options;
|
||||
using JdeScoping.ExcelIO.Options;
|
||||
using JdeScoping.Database;
|
||||
using JdeScoping.Infrastructure.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -11,14 +13,21 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseWindowsService();
|
||||
|
||||
// Run database migrations (skip in Testing environment)
|
||||
// Note: IDatabaseMigrator interface enables mocking for integration tests
|
||||
if (!builder.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
var migrator = new DatabaseMigrator(builder.Configuration);
|
||||
// Create early logger for startup diagnostics
|
||||
using var loggerFactory = LoggerFactory.Create(b => b
|
||||
.AddConfiguration(builder.Configuration.GetSection("Logging"))
|
||||
.AddConsole());
|
||||
var startupLogger = loggerFactory.CreateLogger("JdeScoping.Host.Startup");
|
||||
|
||||
IDatabaseMigrator migrator = new DatabaseMigrator(builder.Configuration);
|
||||
var migrationResult = migrator.Migrate();
|
||||
|
||||
if (!migrationResult.Successful)
|
||||
{
|
||||
Console.WriteLine($"Database migration failed: {migrationResult.Error?.Message}");
|
||||
startupLogger.LogError(migrationResult.Error, "Database migration failed: {ErrorMessage}", migrationResult.Error?.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +45,13 @@ builder.Services
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Migrate existing secrets to SecureStore (skip in Testing environment)
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
var migrator = app.Services.GetRequiredService<SecretsMigrator>();
|
||||
migrator.MigrateIfNeeded();
|
||||
}
|
||||
|
||||
// Startup validation - verify critical services are registered
|
||||
ValidateServices(app.Services);
|
||||
|
||||
@@ -64,11 +80,15 @@ static void ValidateServices(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var provider = scope.ServiceProvider;
|
||||
var logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("JdeScoping.Host.Startup");
|
||||
|
||||
// Validate Options classes are bound
|
||||
_ = provider.GetRequiredService<IOptions<DataAccessOptions>>();
|
||||
_ = provider.GetRequiredService<IOptions<DataSyncOptions>>();
|
||||
_ = provider.GetRequiredService<IOptions<ExcelExportOptions>>();
|
||||
_ = provider.GetRequiredService<IOptions<SearchProcessingOptions>>();
|
||||
Console.WriteLine("Service validation completed successfully.");
|
||||
logger.LogInformation("Service validation completed successfully");
|
||||
}
|
||||
|
||||
// Enable WebApplicationFactory<Program> for integration testing
|
||||
public partial class Program { }
|
||||
|
||||
@@ -128,6 +128,13 @@
|
||||
"UseFileDataSource": false,
|
||||
"FileDirectory": "DevData"
|
||||
},
|
||||
"SecureStore": {
|
||||
"StorePath": "data/secrets.json",
|
||||
"KeyFilePath": "data/secrets.key",
|
||||
"MasterKeyEnvVar": "SCOPINGTOOL_MASTER_KEY",
|
||||
"AutoCreateStore": true,
|
||||
"MigrateExistingSecrets": true
|
||||
},
|
||||
"WorkProcessor": {
|
||||
"Enabled": true,
|
||||
"WorkInterval": "00:00:05",
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace JdeScoping.Infrastructure.Auth;
|
||||
/// Fake authentication service for development mode.
|
||||
/// Accepts any credentials and returns a predefined user.
|
||||
/// </summary>
|
||||
public sealed class FakeAuthService : IAuthService
|
||||
public sealed class FakeAuthService : IAuthenticationService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<AuthResult> AuthenticateAsync(
|
||||
@@ -20,25 +20,6 @@ public sealed class FakeAuthService : IAuthService
|
||||
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
|
||||
|
||||
@@ -9,9 +9,29 @@ using Microsoft.Extensions.Options;
|
||||
namespace JdeScoping.Infrastructure.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP-based authentication service implementation using System.DirectoryServices.Protocols
|
||||
/// LDAP-based authentication service implementation using System.DirectoryServices.Protocols.
|
||||
/// Note: This service only implements IAuthenticationService because LDAP requires credentials
|
||||
/// for each operation - standalone user lookups are not possible without stored credentials.
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService : IAuthService
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Async Pattern Note:</strong> This service uses <c>Task.Run</c> to wrap synchronous
|
||||
/// LDAP operations. This is intentional because <see cref="System.DirectoryServices.Protocols"/>
|
||||
/// does not provide a native async API.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The synchronous methods <see cref="LdapConnection.Bind"/> and <see cref="LdapConnection.SendRequest"/>
|
||||
/// perform blocking I/O operations that can take several seconds to complete (especially with network
|
||||
/// timeouts). Wrapping these in <c>Task.Run</c> offloads them to the thread pool, preventing the calling
|
||||
/// async context (e.g., ASP.NET Core request thread) from being blocked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This pattern is an accepted workaround when no native async API exists. The alternative would be
|
||||
/// to use a different LDAP library with async support, but System.DirectoryServices.Protocols is the
|
||||
/// standard .NET library for LDAP operations and is well-tested for enterprise Active Directory scenarios.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : IAuthenticationService
|
||||
{
|
||||
private const string LdapLookupFormat = "(sAMAccountName={0})";
|
||||
|
||||
@@ -93,38 +113,15 @@ public sealed class LdapAuthService : IAuthService
|
||||
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);
|
||||
using var connection = await BindConnectionAsync(serverUrl, username, password, ct);
|
||||
return true;
|
||||
}
|
||||
catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
|
||||
@@ -140,10 +137,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
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);
|
||||
using var connection = await BindConnectionAsync(serverUrl, username, password, ct);
|
||||
|
||||
var searchRequest = new SearchRequest(
|
||||
_options.SearchBase,
|
||||
@@ -151,6 +145,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
SearchScope.Subtree,
|
||||
"memberOf");
|
||||
|
||||
// Task.Run wraps synchronous SendRequest() - see class remarks for rationale
|
||||
var response = (SearchResponse)await Task.Run(
|
||||
() => connection.SendRequest(searchRequest), ct);
|
||||
|
||||
@@ -178,10 +173,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
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);
|
||||
using var connection = await BindConnectionAsync(serverUrl, username, password, ct);
|
||||
|
||||
var searchRequest = new SearchRequest(
|
||||
_options.SearchBase,
|
||||
@@ -189,6 +181,7 @@ public sealed class LdapAuthService : IAuthService
|
||||
SearchScope.Subtree,
|
||||
"distinguishedName", "givenName", "sn", "mail", "title");
|
||||
|
||||
// Task.Run wraps synchronous SendRequest() - see class remarks for rationale
|
||||
var response = (SearchResponse)await Task.Run(
|
||||
() => connection.SendRequest(searchRequest), ct);
|
||||
|
||||
@@ -206,6 +199,24 @@ public sealed class LdapAuthService : IAuthService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and binds an LDAP connection with the specified credentials.
|
||||
/// The caller is responsible for disposing the returned connection.
|
||||
/// </summary>
|
||||
private async Task<LdapConnection> BindConnectionAsync(
|
||||
string serverUrl,
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var connection = CreateConnection(serverUrl);
|
||||
connection.Credential = new NetworkCredential(username, password);
|
||||
connection.AuthType = AuthType.Negotiate;
|
||||
// Task.Run wraps synchronous Bind() - see class remarks for rationale
|
||||
await Task.Run(() => connection.Bind(), ct);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private LdapConnection CreateConnection(string serverUrl)
|
||||
{
|
||||
var connection = new LdapConnection(serverUrl);
|
||||
|
||||
@@ -33,26 +33,27 @@ public static class InfrastructureDependencyInjection
|
||||
|
||||
if (ldapOptions?.UseFakeAuth == true)
|
||||
{
|
||||
services.AddScoped<IAuthService, FakeAuthService>();
|
||||
services.AddScoped<IAuthenticationService, FakeAuthService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddScoped<IAuthService, LdapAuthService>();
|
||||
services.AddScoped<IAuthenticationService, LdapAuthService>();
|
||||
}
|
||||
|
||||
// Register RSA key service for login encryption
|
||||
// Register SecureStore for encrypted secrets storage
|
||||
services.Configure<SecureStoreOptions>(
|
||||
configuration.GetSection(SecureStoreOptions.SectionName));
|
||||
|
||||
services.AddSingleton<ISecureStoreService, SecureStoreService>();
|
||||
|
||||
// Register RSA key service backed by SecureStore
|
||||
services.Configure<RsaKeyOptions>(
|
||||
configuration.GetSection(RsaKeyOptions.SectionName));
|
||||
|
||||
var rsaKeyOptions = configuration
|
||||
.GetSection(RsaKeyOptions.SectionName)
|
||||
.Get<RsaKeyOptions>() ?? new RsaKeyOptions();
|
||||
services.AddSingleton<IRsaKeyService, SecureStoreRsaKeyService>();
|
||||
|
||||
var keyPath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath)
|
||||
? rsaKeyOptions.KeyFilePath
|
||||
: Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath);
|
||||
|
||||
services.AddSingleton<IRsaKeyService>(new RsaKeyService(keyPath));
|
||||
// Register secrets migrator for one-time migration of existing secrets
|
||||
services.AddSingleton<SecretsMigrator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<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="SecureStore" Version="1.2.0" />
|
||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user