refactor(infrastructure): align persistence with procedure result models

This commit is contained in:
2026-10-31 17:09:21 +01:00
parent 11ebb0567a
commit 9b60f5116b
8 changed files with 35 additions and 495 deletions

View File

@@ -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<DatabaseOptions>() ?? new DatabaseOptions();
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("Connection string 'TaskFlowDb' is not configured.");
}
services.AddDbContext<TaskFlowDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
});
options.UseSqlServer(connectionString);
});
services.Configure<DatabaseOptions>(configuration.GetSection(DatabaseOptions.SectionName));
services.AddScoped<IWorkflowRepository, WorkflowRepository>();
services.AddScoped<IDocumentRepository, DocumentRepository>();

View File

@@ -7,20 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="11.0.0-rc.1.26425.128" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="11.0.0-rc.1.26425.128" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="11.0.0-rc.1.26425.128" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.TaskFlow.Application\DigitalData.TaskFlow.Application.csproj" />
<ProjectReference Include="..\..\core\DigitalData.TaskFlow.Domain\DigitalData.TaskFlow.Domain.csproj" />
<ProjectReference Include="..\..\core\DigitalData.TaskFlow.Application\DigitalData.TaskFlow.Application.csproj" />
<ProjectReference Include="..\..\core\DigitalData.TaskFlow.Domain\DigitalData.TaskFlow.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Options\" />
</ItemGroup>
</Project>

View File

@@ -1,22 +0,0 @@
namespace DigitalData.TaskFlow.Infrastructure.Options;
/// <summary>
/// Holds infrastructure database options.
/// </summary>
public sealed class DatabaseOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Database";
/// <summary>
/// Gets or sets command timeout in seconds for SQL operations.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets object type passed to workflow listing procedure.
/// </summary>
public string WorkflowListingObjectType { get; set; } = "Ferdinand";
}

View File

@@ -1,111 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace DigitalData.TaskFlow.Infrastructure.Persistence.Models;
/// <summary>
/// Represents a stored procedure row shape for workflow listing data.
/// </summary>
public sealed class WorkflowListingReadModel
{
/// <summary>
/// Gets or sets the process identifier.
/// </summary>
[Column("processid")]
public int ProcessId { get; set; }
/// <summary>
/// Gets or sets the profile identifier.
/// </summary>
[Column("profileid")]
public byte ProfileId { get; set; }
/// <summary>
/// Gets or sets the voucher type.
/// </summary>
[Column("belegart")]
public string VoucherType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the voucher number.
/// </summary>
[Column("belegnr")]
public string VoucherNumber { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the voucher date.
/// </summary>
[Column("belegdatum")]
public DateTime VoucherDate { get; set; }
/// <summary>
/// Gets or sets the cost center.
/// </summary>
[Column("kostenstelle")]
public string CostCenter { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the cost center description.
/// </summary>
[Column("kostenstellenbezeichnung")]
public string CostCenterDescription { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client number.
/// </summary>
[Column("mandantnr")]
public short ClientNumber { get; set; }
/// <summary>
/// Gets or sets the client name.
/// </summary>
[Column("mandantbez")]
public string ClientName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the supplier number.
/// </summary>
[Column("lieferantnr")]
public string SupplierNumber { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the supplier name.
/// </summary>
[Column("lieferantbez")]
public string SupplierName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the net amount.
/// </summary>
[Column("netto")]
public double NetAmount { get; set; }
/// <summary>
/// Gets or sets the gross amount.
/// </summary>
[Column("brutto")]
public double GrossAmount { get; set; }
/// <summary>
/// Gets or sets the discount amount.
/// </summary>
[Column("skontobetrag")]
public double DiscountAmount { get; set; }
/// <summary>
/// Gets or sets the currency.
/// </summary>
[Column("waehrung")]
public string Currency { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the priority.
/// </summary>
[Column("priority")]
public byte Priority { get; set; }
/// <summary>
/// Gets or sets the source group text.
/// </summary>
[Column("GROUP_TEXT")]
public string GroupText { get; set; } = string.Empty;
}

View File

@@ -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<TaskFlowDbContext> options) : DbContext(options)
{
/// <summary>
/// Gets the keyless result model used for workflow listing procedure reads.
/// Gets the keyless result model used for workflow procedure reads.
/// </summary>
public DbSet<WorkflowListingReadModel> WorkflowListingReadModels => Set<WorkflowListingReadModel>();
public DbSet<Workflow> Workflows => Set<Workflow>();
/// <summary>
/// Gets the keyless entity mapped to TBCUST_DOC_BASE64.
/// Gets the keyless result model used for document procedure reads.
/// </summary>
public DbSet<CustDocBase64> CustDocBase64 => Set<CustDocBase64>();
public DbSet<Document> Documents => Set<Document>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<WorkflowListingReadModel>(entity =>
modelBuilder.Entity<Workflow>(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<CustDocBase64>(entity =>
modelBuilder.Entity<Document>(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)");
});
}
}

View File

@@ -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.

View File

@@ -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;
/// <summary>
/// Document repository implementation for binary document retrieval.
/// Document repository implementation for reading document listing data and applying close operations.
/// </summary>
public sealed class DocumentRepository : IDocumentRepository
public sealed class DocumentRepository(TaskFlowDbContext context) : IDocumentRepository
{
/// <summary>
/// Retrieves binary document content by identifier.
/// </summary>
/// <param name="id">Document identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Binary document payload when found; otherwise <see langword="null"/>.</returns>
public Task<DocumentBinaryResult?> GetAsync(string id, CancellationToken cancellationToken)
public async Task<Document?> 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();
}
}
}

View File

@@ -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;
/// <summary>
/// Workflow repository implementation for reading workflow listing data and applying close operations.
/// </summary>
public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<DatabaseOptions> databaseOptions) : IWorkflowRepository
public sealed class WorkflowRepository(TaskFlowDbContext context) : IWorkflowRepository
{
/// <summary>
/// Retrieves grouped workflow data for a user.
/// </summary>
/// <param name="wisagId">WISAG user identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Grouped workflow response.</returns>
public async Task<WorkflowSourcesResponse> GetAsync(int wisagId, CancellationToken cancellationToken)
public async Task<Workflow?> 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<SourceBlock>();
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 };
}
/// <summary>
/// Applies a closing decision to a workflow item.
/// </summary>
/// <param name="wisagId">WISAG user identifier.</param>
/// <param name="source">Source system name.</param>
/// <param name="processId">Process identifier.</param>
/// <param name="decision">Closing decision payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the close operation.</returns>
public Task<CloseWorkflowResult> 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<CloseWorkflowResult> 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<int?> 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<IReadOnlyList<WorkflowListingReadModel>> 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<string?> 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<JsonElement> 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();
}
}