Refactor workflows and improve API responses

- Added `BackToPerMethodId` constant and updated validation rules in `CloseWorkflowCommandValidator`.
- Refactored `GetWorkflowsQueryHandler` for simpler `hasListingData` logic.
- Replaced strongly-typed properties with `JsonElement` in `ContractsSourceBlock` and `FerdinandSourceBlock`.
- Updated `WorkflowListingReadModel` to use smaller data types for efficiency.
- Refactored `WorkflowRepository` to use JSON-based data retrieval and parsing.
- Introduced `MetaOnlyResponse` and `WorkflowsResponse` for consistent API responses.
- Updated `DocumentsController` and `WorkflowsController` to use new response types.
- Added `ApiKeyAuthMiddleware` for API key validation and integrated it into the middleware pipeline.
- Refactored `ExceptionHandlingMiddleware` to use `MetaOnlyResponse` for error handling.
- Performed general cleanup and modernization for improved readability and maintainability.
This commit is contained in:
2026-10-10 20:47:31 +02:00
parent 600374a6f0
commit 70e82567d8
14 changed files with 352 additions and 166 deletions

View File

@@ -17,7 +17,7 @@ public sealed class WorkflowListingReadModel
/// Gets or sets the profile identifier.
/// </summary>
[Column("profileid")]
public int ProfileId { get; set; }
public byte ProfileId { get; set; }
/// <summary>
/// Gets or sets the voucher type.
@@ -53,7 +53,7 @@ public sealed class WorkflowListingReadModel
/// Gets or sets the client number.
/// </summary>
[Column("mandantnr")]
public int ClientNumber { get; set; }
public short ClientNumber { get; set; }
/// <summary>
/// Gets or sets the client name.
@@ -101,7 +101,7 @@ public sealed class WorkflowListingReadModel
/// Gets or sets the priority.
/// </summary>
[Column("priority")]
public int Priority { get; set; }
public byte Priority { get; set; }
/// <summary>
/// Gets or sets the source group text.

View File

@@ -3,6 +3,8 @@ using DigitalData.TaskFlow.Domain.Closing;
using DigitalData.TaskFlow.Domain.Workflows;
using DigitalData.TaskFlow.Infrastructure.Options;
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;
@@ -22,78 +24,72 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
/// <returns>Grouped workflow response.</returns>
public async Task<WorkflowSourcesResponse> GetAsync(int wisagId, CancellationToken cancellationToken)
{
const string resultType = "table";
var objectType = string.IsNullOrWhiteSpace(databaseOptions.Value.WorkflowListingObjectType)
? "Ferdinand"
: databaseOptions.Value.WorkflowListingObjectType;
var rows = await dbContext
.WorkflowListingReadModels
.FromSqlInterpolated($"EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID={wisagId}, @pRESULT_TYPE={resultType}, @pOBJECT_TYPE={objectType}")
.AsNoTracking()
.ToListAsync(cancellationToken);
if (rows.Count == 0)
var jsonPayload = await ExecuteListingJsonAsync(wisagId, objectType, cancellationToken);
if (string.IsNullOrWhiteSpace(jsonPayload))
{
var metaCode = await TryGetWorkflowMetaCodeAsync(wisagId, objectType, cancellationToken);
return new WorkflowSourcesResponse { Sources = [] };
}
if (metaCode == 2)
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))
{
return new WorkflowSourcesResponse
continue;
}
var sourceName = sourceNameElement.GetString();
if (string.Equals(sourceName, "Ferdinand", StringComparison.OrdinalIgnoreCase))
{
sources.Add(new FerdinandSourceBlock
{
Sources = []
};
Source = "Ferdinand",
Profile = ReadArray(sourceElement, "profile"),
Listing = ReadArray(sourceElement, "Listing"),
Approvals = ReadArray(sourceElement, "approvals"),
Document = ReadArray(sourceElement, "document"),
ClosingMethod = ReadArray(sourceElement, "closing_method"),
RejectionReason = ReadArray(sourceElement, "rejection_reason")
});
continue;
}
if (string.Equals(sourceName, "Contracts", StringComparison.OrdinalIgnoreCase))
{
sources.Add(new ContractsSourceBlock
{
Source = "Contracts",
Profile = ReadArray(sourceElement, "profile"),
Listing = ReadArray(sourceElement, "Listing"),
Approvals = ReadArray(sourceElement, "approvals"),
Document = ReadArray(sourceElement, "document"),
ClosingMethod = ReadArray(sourceElement, "closing_method")
});
}
}
var listing = rows
.Select(row => new WorkflowListingItem
{
ProcessId = row.ProcessId,
ProfileId = row.ProfileId,
VoucherType = row.VoucherType,
VoucherNumber = row.VoucherNumber,
VoucherDate = DateOnly.FromDateTime(row.VoucherDate),
CostCenter = row.CostCenter,
CostCenterDescription = row.CostCenterDescription,
ClientNumber = row.ClientNumber,
ClientName = row.ClientName,
SupplierNumber = row.SupplierNumber,
SupplierName = row.SupplierName,
NetAmount = Convert.ToDecimal(row.NetAmount),
GrossAmount = Convert.ToDecimal(row.GrossAmount),
DiscountAmount = Convert.ToDecimal(row.DiscountAmount),
Currency = row.Currency,
Priority = row.Priority
})
.ToList();
var ferdinandBlock = new FerdinandSourceBlock
{
Source = "Ferdinand",
Profiles = [],
Listing = listing,
Approvals = [],
Documents = [],
ClosingMethods = [],
RejectionReasons = []
};
var contractsBlock = new ContractsSourceBlock
{
Source = "Contracts",
Profiles = [],
Listing = [],
Approvals = [],
Documents = [],
ClosingMethods = [],
RejectionReasons = []
};
return new WorkflowSourcesResponse
{
Sources = [ferdinandBlock, contractsBlock]
};
return new WorkflowSourcesResponse { Sources = sources };
}
/// <summary>
@@ -124,19 +120,17 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
});
}
return EvaluateCloseOutcomeAsync(wisagId, processId, cancellationToken);
return EvaluateCloseOutcomeAsync(wisagId, source, processId, cancellationToken);
}
private async Task<CloseWorkflowResult> EvaluateCloseOutcomeAsync(
int wisagId,
string source,
int processId,
CancellationToken cancellationToken)
{
var activeForUser = await dbContext.Database
.SqlQuery<int>($"SELECT COUNT(1) FROM dbo.TBCUST_F19_LISTING_RESULT WHERE userid = {wisagId} AND processid = {processId}")
.SingleAsync(cancellationToken);
if (activeForUser > 0)
var rows = await ExecuteListingTableAsync(wisagId, source, cancellationToken);
if (rows.Any(row => row.ProcessId == processId))
{
return new CloseWorkflowResult
{
@@ -146,11 +140,8 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
};
}
var activeAnyUser = await dbContext.Database
.SqlQuery<int>($"SELECT COUNT(1) FROM dbo.TBCUST_F19_LISTING_RESULT WHERE processid = {processId}")
.SingleAsync(cancellationToken);
if (activeAnyUser > 0)
var metaCode = await TryGetWorkflowMetaCodeAsync(wisagId, source, cancellationToken);
if (metaCode == 2)
{
return new CloseWorkflowResult
{
@@ -160,20 +151,6 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
};
}
var inHistory = await dbContext.Database
.SqlQuery<int>($"SELECT COUNT(1) FROM dbo.TBMWF_PROFILE_OBJ_STATE_HISTORY WHERE OBJ_ID = {processId}")
.SingleAsync(cancellationToken);
if (inHistory > 0)
{
return new CloseWorkflowResult
{
ProcessId = processId,
IsClosed = false,
Outcome = CloseWorkflowOutcome.AlreadyClosed
};
}
return new CloseWorkflowResult
{
ProcessId = 0,
@@ -184,12 +161,7 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
private async Task<int?> TryGetWorkflowMetaCodeAsync(int wisagId, string objectType, CancellationToken cancellationToken)
{
const string resultType = "json";
var jsonPayload = await dbContext.Database
.SqlQuery<string>($"EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID={wisagId}, @pRESULT_TYPE={resultType}, @pOBJECT_TYPE={objectType}")
.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
var jsonPayload = await ExecuteListingJsonAsync(wisagId, objectType, cancellationToken);
if (string.IsNullOrWhiteSpace(jsonPayload))
{
@@ -200,31 +172,83 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
{
using var document = JsonDocument.Parse(jsonPayload);
if (!document.RootElement.TryGetProperty("response", out var responseElement))
{
return null;
}
if (!responseElement.TryGetProperty("meta", out var metaElement) || metaElement.ValueKind != JsonValueKind.Array)
{
return null;
}
if (metaElement.GetArrayLength() == 0)
{
return null;
}
if (!metaElement[0].TryGetProperty("code", out var codeElement))
{
return null;
}
return codeElement.GetInt32();
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}")
.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);
if (!await reader.ReadAsync(cancellationToken) || reader.FieldCount == 0 || reader.IsDBNull(0))
{
return null;
}
return reader.GetString(0);
}
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, string propertyName)
{
if (!sourceElement.TryGetProperty(propertyName, out var propertyElement) || propertyElement.ValueKind != JsonValueKind.Array)
{
return [];
}
return propertyElement.EnumerateArray().Select(item => item.Clone()).ToList();
}
}