From 9b60f5116b697f685186f41749c37d145e00156a Mon Sep 17 00:00:00 2001 From: TekH Date: Sat, 31 Oct 2026 17:09:21 +0100 Subject: [PATCH] refactor(infrastructure): align persistence with procedure result models --- .../DependencyInjection.cs | 12 +- ...DigitalData.TaskFlow.Infrastructure.csproj | 13 +- .../Options/DatabaseOptions.cs | 22 -- .../Models/WorkflowListingReadModel.cs | 111 ------- .../Persistence/TaskFlowDbContext.cs | 21 +- .../README.md | 46 --- .../Repositories/DocumentRepository.cs | 27 +- .../Repositories/WorkflowRepository.cs | 278 +----------------- 8 files changed, 35 insertions(+), 495 deletions(-) delete mode 100644 src/infrastructure/DigitalData.TaskFlow.Infrastructure/Options/DatabaseOptions.cs delete mode 100644 src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs delete mode 100644 src/infrastructure/DigitalData.TaskFlow.Infrastructure/README.md diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DependencyInjection.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DependencyInjection.cs index 37baa5c..12b43f6 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DependencyInjection.cs +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DependencyInjection.cs @@ -1,5 +1,4 @@ using DigitalData.TaskFlow.Application.Common.Interfaces; -using DigitalData.TaskFlow.Infrastructure.Options; using DigitalData.TaskFlow.Infrastructure.Persistence; using DigitalData.TaskFlow.Infrastructure.Repositories; using Microsoft.EntityFrameworkCore; @@ -13,23 +12,14 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) { var connectionString = configuration.GetConnectionString("TaskFlowDb"); - var databaseOptions = configuration.GetSection(DatabaseOptions.SectionName).Get() ?? new DatabaseOptions(); - if (string.IsNullOrWhiteSpace(connectionString)) - { throw new InvalidOperationException("Connection string 'TaskFlowDb' is not configured."); - } services.AddDbContext(options => { - options.UseSqlServer(connectionString, sqlOptions => - { - sqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds); - }); + options.UseSqlServer(connectionString); }); - services.Configure(configuration.GetSection(DatabaseOptions.SectionName)); - services.AddScoped(); services.AddScoped(); diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DigitalData.TaskFlow.Infrastructure.csproj b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DigitalData.TaskFlow.Infrastructure.csproj index 4d4386b..14f26cf 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DigitalData.TaskFlow.Infrastructure.csproj +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/DigitalData.TaskFlow.Infrastructure.csproj @@ -7,20 +7,15 @@ - - + + - - + + - - - - - diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Options/DatabaseOptions.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Options/DatabaseOptions.cs deleted file mode 100644 index 7c65959..0000000 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Options/DatabaseOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace DigitalData.TaskFlow.Infrastructure.Options; - -/// -/// Holds infrastructure database options. -/// -public sealed class DatabaseOptions -{ - /// - /// Configuration section name. - /// - public const string SectionName = "Database"; - - /// - /// Gets or sets command timeout in seconds for SQL operations. - /// - public int CommandTimeoutSeconds { get; set; } = 30; - - /// - /// Gets or sets object type passed to workflow listing procedure. - /// - public string WorkflowListingObjectType { get; set; } = "Ferdinand"; -} diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs deleted file mode 100644 index f65edb9..0000000 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; - -namespace DigitalData.TaskFlow.Infrastructure.Persistence.Models; - -/// -/// Represents a stored procedure row shape for workflow listing data. -/// -public sealed class WorkflowListingReadModel -{ - /// - /// Gets or sets the process identifier. - /// - [Column("processid")] - public int ProcessId { get; set; } - - /// - /// Gets or sets the profile identifier. - /// - [Column("profileid")] - public byte ProfileId { get; set; } - - /// - /// Gets or sets the voucher type. - /// - [Column("belegart")] - public string VoucherType { get; set; } = string.Empty; - - /// - /// Gets or sets the voucher number. - /// - [Column("belegnr")] - public string VoucherNumber { get; set; } = string.Empty; - - /// - /// Gets or sets the voucher date. - /// - [Column("belegdatum")] - public DateTime VoucherDate { get; set; } - - /// - /// Gets or sets the cost center. - /// - [Column("kostenstelle")] - public string CostCenter { get; set; } = string.Empty; - - /// - /// Gets or sets the cost center description. - /// - [Column("kostenstellenbezeichnung")] - public string CostCenterDescription { get; set; } = string.Empty; - - /// - /// Gets or sets the client number. - /// - [Column("mandantnr")] - public short ClientNumber { get; set; } - - /// - /// Gets or sets the client name. - /// - [Column("mandantbez")] - public string ClientName { get; set; } = string.Empty; - - /// - /// Gets or sets the supplier number. - /// - [Column("lieferantnr")] - public string SupplierNumber { get; set; } = string.Empty; - - /// - /// Gets or sets the supplier name. - /// - [Column("lieferantbez")] - public string SupplierName { get; set; } = string.Empty; - - /// - /// Gets or sets the net amount. - /// - [Column("netto")] - public double NetAmount { get; set; } - - /// - /// Gets or sets the gross amount. - /// - [Column("brutto")] - public double GrossAmount { get; set; } - - /// - /// Gets or sets the discount amount. - /// - [Column("skontobetrag")] - public double DiscountAmount { get; set; } - - /// - /// Gets or sets the currency. - /// - [Column("waehrung")] - public string Currency { get; set; } = string.Empty; - - /// - /// Gets or sets the priority. - /// - [Column("priority")] - public byte Priority { get; set; } - - /// - /// Gets or sets the source group text. - /// - [Column("GROUP_TEXT")] - public string GroupText { get; set; } = string.Empty; -} diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/TaskFlowDbContext.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/TaskFlowDbContext.cs index e5c5dc7..20b3bc1 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/TaskFlowDbContext.cs +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/TaskFlowDbContext.cs @@ -1,5 +1,4 @@ -using DigitalData.TaskFlow.Domain.Entities; -using DigitalData.TaskFlow.Infrastructure.Persistence.Models; +using DigitalData.TaskFlow.Domain.ProcedureResults; using Microsoft.EntityFrameworkCore; namespace DigitalData.TaskFlow.Infrastructure.Persistence; @@ -10,29 +9,33 @@ namespace DigitalData.TaskFlow.Infrastructure.Persistence; public sealed class TaskFlowDbContext(DbContextOptions options) : DbContext(options) { /// - /// Gets the keyless result model used for workflow listing procedure reads. + /// Gets the keyless result model used for workflow procedure reads. /// - public DbSet WorkflowListingReadModels => Set(); + public DbSet Workflows => Set(); /// - /// Gets the keyless entity mapped to TBCUST_DOC_BASE64. + /// Gets the keyless result model used for document procedure reads. /// - public DbSet CustDocBase64 => Set(); + public DbSet Documents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); entity.ToView(null); + entity.Property(x => x.Code).HasColumnName("CODE").HasColumnType("int"); + entity.Property(x => x.Result).HasColumnName("RESULT").HasColumnType("nvarchar(max)"); }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { entity.HasNoKey(); - entity.ToTable("TBCUST_DOC_BASE64", "dbo"); + entity.ToView(null); + entity.Property(x => x.Code).HasColumnName("CODE").HasColumnType("int"); + entity.Property(x => x.Result).HasColumnName("RESULT").HasColumnType("nvarchar(max)"); }); } } diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/README.md b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/README.md deleted file mode 100644 index 4af281c..0000000 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# DigitalData.TaskFlow.Infrastructure - -Dieses Projekt implementiert technische Integrationen der Anwendung. - -## Zweck - -Die Infrastructure-Schicht setzt die in Application definierten Abstraktionen konkret um. Dazu gehoeren Datenbankzugriffe (EF Core, Stored Procedures) und spaeter ggf. DMS- oder externe Service-Anbindungen. - -## Aktueller Inhalt - -- `DependencyInjection.cs` - - Einstiegspunkt fuer die Registrierung technischer Dienste. -- `Options/` - - Platz fuer Infrastruktur-spezifische Optionen (falls benoetigt). - -## Geplante Inhalte (naechste Phasen) - -- `Persistence/` - - `TaskFlowDbContext` mit SQL-Server-Konfiguration. - - Entity-Konfigurationen inklusive Feld-Mapping (DB-Namen -> PascalCase-Modelle). -- `Repositories/` - - Implementierungen fuer: - - Workflow-Lesezugriff (u. a. `PRCUST_F19_GET_LISTING_DATA`) - - Workflow-Closing (Write-Procedure) - - Dokument-Binary-Zugriff -- `Persistence/Models/` - - Keyless Modelle fuer Procedure-Ergebnisse. - -## Wichtiger Hinweis zu DB-Spaltennamen - -- Spaltennamen in `Persistence/Models` (z. B. `WorkflowListingReadModel`) basieren aktuell auf: - - read-only Abfragen auf `sys.columns` (u. a. `TBCUST_F19_LISTING_RESULT`), - - beobachteten Result-Sets von `PRCUST_F19_GET_LISTING_DATA` im `table`-Modus. -- Vor produktiven Aenderungen muss das finale SP-Resultset read-only verifiziert werden, idealerweise mit - `sp_describe_first_result_set` oder einer gleichwertigen Metadata-Pruefung. -- Domain-Modelle bleiben davon getrennt: DB-Feldnamen werden nur in Infrastructure gemappt, nicht in Domain. - -## Architektur-Regeln - -- Keine Controller- oder HTTP-Logik. -- Keine fachliche Regel-Definition; nur technische Umsetzung. -- SQL-Zugriff stets parameterisiert und sicher. - -## Rolle im Gesamtsystem - -Infrastructure kapselt alle externen Abhaengigkeiten. Dadurch bleibt die Application-Schicht testbar und von Technologie-Details entkoppelt. diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/DocumentRepository.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/DocumentRepository.cs index 796a743..a937e32 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/DocumentRepository.cs +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/DocumentRepository.cs @@ -1,25 +1,22 @@ using DigitalData.TaskFlow.Application.Common.Interfaces; -using DigitalData.TaskFlow.Domain.Documents; +using DigitalData.TaskFlow.Domain.ProcedureResults; +using DigitalData.TaskFlow.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; namespace DigitalData.TaskFlow.Infrastructure.Repositories; /// -/// Document repository implementation for binary document retrieval. +/// Document repository implementation for reading document listing data and applying close operations. /// -public sealed class DocumentRepository : IDocumentRepository +public sealed class DocumentRepository(TaskFlowDbContext context) : IDocumentRepository { - /// - /// Retrieves binary document content by identifier. - /// - /// Document identifier. - /// Cancellation token. - /// Binary document payload when found; otherwise . - public Task GetAsync(string id, CancellationToken cancellationToken) + public async Task GetAsync(int userId, long processId, long docId, CancellationToken cancellationToken) { - _ = id; - _ = cancellationToken; + var rows = await context.Documents + .FromSqlInterpolated(@$"EXEC [PRCUST_F19_GET_DOCUMENT] @pRESULT_TYPE = 'json', @pOBJECT_TYPE = 'Ferdinand', @pUSER_ID = {userId}, @pPROCESS_ID = {processId}, @pDOC_ID = {docId}") + .AsNoTracking() + .ToListAsync(cancellationToken); - throw new NotSupportedException( - "Document retrieval procedure/DMS contract is not finalized yet. Configure final integration before enabling GetAsync."); + return rows.FirstOrDefault(); } -} +} \ No newline at end of file diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs index 2797f2a..2487116 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs @@ -1,288 +1,22 @@ using DigitalData.TaskFlow.Application.Common.Interfaces; -using DigitalData.TaskFlow.Domain.Closing; -using DigitalData.TaskFlow.Domain.Workflows; -using DigitalData.TaskFlow.Infrastructure.Options; +using DigitalData.TaskFlow.Domain.ProcedureResults; using DigitalData.TaskFlow.Infrastructure.Persistence; -using DigitalData.TaskFlow.Infrastructure.Persistence.Models; -using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using System.Text.Json; namespace DigitalData.TaskFlow.Infrastructure.Repositories; /// /// Workflow repository implementation for reading workflow listing data and applying close operations. /// -public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions databaseOptions) : IWorkflowRepository +public sealed class WorkflowRepository(TaskFlowDbContext context) : IWorkflowRepository { - /// - /// Retrieves grouped workflow data for a user. - /// - /// WISAG user identifier. - /// Cancellation token. - /// Grouped workflow response. - public async Task GetAsync(int wisagId, CancellationToken cancellationToken) + public async Task GetByWisagIdAsync(int wisagId, CancellationToken cancellationToken) { - var objectType = string.IsNullOrWhiteSpace(databaseOptions.Value.WorkflowListingObjectType) - ? "Ferdinand" - : databaseOptions.Value.WorkflowListingObjectType; - - var jsonPayload = await ExecuteListingJsonAsync(wisagId, objectType, cancellationToken); - if (string.IsNullOrWhiteSpace(jsonPayload)) - { - return new WorkflowSourcesResponse { Sources = [] }; - } - - using var document = JsonDocument.Parse(jsonPayload); - if (!document.RootElement.TryGetProperty("response", out var responseElement)) - { - return new WorkflowSourcesResponse { Sources = [] }; - } - - if (TryReadMetaCode(responseElement) == 2) - { - return new WorkflowSourcesResponse { Sources = [] }; - } - - var sources = new List(); - if (!responseElement.TryGetProperty("sources", out var sourcesElement) || sourcesElement.ValueKind != JsonValueKind.Array) - { - return new WorkflowSourcesResponse { Sources = sources }; - } - - foreach (var sourceElement in sourcesElement.EnumerateArray()) - { - if (!sourceElement.TryGetProperty("source", out var sourceNameElement)) - { - continue; - } - - var sourceName = sourceNameElement.GetString(); - if (string.Equals(sourceName, "Ferdinand", StringComparison.OrdinalIgnoreCase)) - { - sources.Add(new FerdinandSourceBlock - { - Source = "Ferdinand", - Profile = ReadArray(sourceElement, "profile", "profiles"), - Listing = ReadArray(sourceElement, "Listing", "Listings"), - Approvals = ReadArray(sourceElement, "approvals"), - Document = ReadArray(sourceElement, "document", "documents"), - ClosingMethod = ReadArray(sourceElement, "closing_method", "closing_methods"), - RejectionReason = ReadArray(sourceElement, "rejection_reason", "rejection_reasons") - }); - - continue; - } - - if (string.Equals(sourceName, "Contracts", StringComparison.OrdinalIgnoreCase)) - { - sources.Add(new ContractsSourceBlock - { - Source = "Contracts", - Profile = ReadArray(sourceElement, "profile", "profiles"), - Listing = ReadArray(sourceElement, "Listing", "Listings"), - Approvals = ReadArray(sourceElement, "approvals"), - Document = ReadArray(sourceElement, "document", "documents"), - ClosingMethod = ReadArray(sourceElement, "closing_method", "closing_methods") - }); - } - } - - return new WorkflowSourcesResponse { Sources = sources }; - } - - /// - /// Applies a closing decision to a workflow item. - /// - /// WISAG user identifier. - /// Source system name. - /// Process identifier. - /// Closing decision payload. - /// Cancellation token. - /// Result of the close operation. - public Task CloseAsync( - int wisagId, - string source, - int processId, - CloseWorkflowDecision decision, - CancellationToken cancellationToken) - { - _ = decision; - - if (!string.Equals(source, "Ferdinand", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new CloseWorkflowResult - { - ProcessId = processId, - IsClosed = false, - Outcome = CloseWorkflowOutcome.NotFound - }); - } - - return EvaluateCloseOutcomeAsync(wisagId, source, processId, cancellationToken); - } - - private async Task EvaluateCloseOutcomeAsync( - int wisagId, - string source, - int processId, - CancellationToken cancellationToken) - { - var rows = await ExecuteListingTableAsync(wisagId, source, cancellationToken); - if (rows.Any(row => row.ProcessId == processId)) - { - return new CloseWorkflowResult - { - ProcessId = processId, - IsClosed = true, - Outcome = CloseWorkflowOutcome.Closed - }; - } - - var metaCode = await TryGetWorkflowMetaCodeAsync(wisagId, source, cancellationToken); - if (metaCode == 2) - { - return new CloseWorkflowResult - { - ProcessId = processId, - IsClosed = false, - Outcome = CloseWorkflowOutcome.NoAccess - }; - } - - return new CloseWorkflowResult - { - ProcessId = 0, - IsClosed = false, - Outcome = CloseWorkflowOutcome.NotFound - }; - } - - private async Task TryGetWorkflowMetaCodeAsync(int wisagId, string objectType, CancellationToken cancellationToken) - { - var jsonPayload = await ExecuteListingJsonAsync(wisagId, objectType, cancellationToken); - - if (string.IsNullOrWhiteSpace(jsonPayload)) - { - return null; - } - - try - { - using var document = JsonDocument.Parse(jsonPayload); - - return document.RootElement.TryGetProperty("response", out var responseElement) - ? TryReadMetaCode(responseElement) - : null; - } - catch (JsonException) - { - return null; - } - } - - private async Task> ExecuteListingTableAsync( - int wisagId, - string objectType, - CancellationToken cancellationToken) - { - const string resultType = "table"; - - return await dbContext - .WorkflowListingReadModels - .FromSqlInterpolated($"EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID={wisagId}, @pRESULT_TYPE={resultType}, @pOBJECT_TYPE={objectType}") + var rows = await context.Workflows + .FromSqlInterpolated(@$"EXEC [PRCUST_F19_GET_LISTING_DATA] @pUSER_ID = {wisagId}, @pRESULT_TYPE = 'json', @pOBJECT_TYPE = 'Ferdinand'") .AsNoTracking() .ToListAsync(cancellationToken); - } - private async Task ExecuteListingJsonAsync(int wisagId, string objectType, CancellationToken cancellationToken) - { - const string commandText = "EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID, @pRESULT_TYPE, @pOBJECT_TYPE"; - const string resultType = "json"; - - await using var command = dbContext.Database.GetDbConnection().CreateCommand(); - command.CommandText = commandText; - - command.Parameters.Add(new SqlParameter("@pUSER_ID", wisagId)); - command.Parameters.Add(new SqlParameter("@pRESULT_TYPE", resultType)); - command.Parameters.Add(new SqlParameter("@pOBJECT_TYPE", objectType)); - - if (command.Connection is null) - { - return null; - } - - if (command.Connection.State != System.Data.ConnectionState.Open) - { - await command.Connection.OpenAsync(cancellationToken); - } - - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - do - { - while (await reader.ReadAsync(cancellationToken)) - { - for (var i = 0; i < reader.FieldCount; i++) - { - if (reader.IsDBNull(i)) - { - continue; - } - - var candidate = reader.GetValue(i) as string; - if (string.IsNullOrWhiteSpace(candidate)) - { - continue; - } - - var jsonStart = candidate.IndexOf('{'); - if (jsonStart < 0) - { - continue; - } - - var jsonPayload = candidate[jsonStart..].Trim(); - if (!string.IsNullOrWhiteSpace(jsonPayload)) - { - return jsonPayload; - } - } - } - } - while (await reader.NextResultAsync(cancellationToken)); - - return null; - } - - private static int? TryReadMetaCode(JsonElement responseElement) - { - if (!responseElement.TryGetProperty("meta", out var metaElement) || metaElement.ValueKind != JsonValueKind.Array) - { - return null; - } - - if (metaElement.GetArrayLength() == 0 || !metaElement[0].TryGetProperty("code", out var codeElement)) - { - return null; - } - - return codeElement.GetInt32(); - } - - private static IReadOnlyList ReadArray(JsonElement sourceElement, params string[] propertyNames) - { - foreach (var propertyName in propertyNames) - { - if (!sourceElement.TryGetProperty(propertyName, out var propertyElement) - || propertyElement.ValueKind != JsonValueKind.Array) - { - continue; - } - - return propertyElement.EnumerateArray().Select(item => item.Clone()).ToList(); - } - - return []; + return rows.FirstOrDefault(); } }