Enforced relationships between activities and indicators, fixed actuals recording

This commit is contained in:
2026-10-22 13:47:54 +02:00
parent 4dd0899209
commit 9835121fd9
23 changed files with 3496 additions and 270 deletions
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Application.Services.Evidence;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
@@ -15,6 +15,10 @@ public interface IEvidenceService
[Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Indicator[]>> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Models;
@@ -28,6 +28,10 @@ public interface IProjectService
[Audit]
Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -189,6 +189,9 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, userId, cancellationToken);
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, actualCost, userId, cancellationToken);
public async Task<Result> UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateEvidenceAsync(request, userId, cancellationToken);
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
@@ -50,6 +50,9 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
}));
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) =>
await RecordIndicatorActualAmountAsync(indicatorIdHash, amount, null, userId, cancellationToken);
public async Task<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
@@ -63,6 +66,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
var changedTotal = await context.Indicators.Where(i => i.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f
.SetProperty(f => f.ActualAmount, amount)
.SetProperty(f => f.ActualCost, f => actualCost.HasValue ? actualCost.Value : f.ActualCost)
.SetProperty(f => f.CreatedBy, userId), cancellationToken);
return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update indicator."));
@@ -130,14 +134,21 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New);
if (request.ActivityId.HasValue && !await context.Activities.AnyAsync(a => a.Id == request.ActivityId.Value && a.ProjectId == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.Create("Activity not found"));
context.Indicators.Add(new Domain.Entities.Indicator
{
ProjectId = request.ProjectId,
ActivityId = request.ActivityId,
Name = request.Name,
UnitOfMeasure = request.UnitOfMeasure,
BaselineAmount = request.BaselineAmount,
TargetAmount = request.TargetAmount,
ActualAmount = request.BaselineAmount,
TargetCost = request.TargetCost,
ActualCost = 0,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
@@ -1,4 +1,4 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Evidence;
@@ -7,7 +7,7 @@ public sealed record UpdateEvidenceRequest([Required] long EvidenceId,
[Required] EvidenceType EvidenceType,
[Required] ApprovalStatus Status);
public sealed record RecordActualRequest([Required] decimal ActualAmount);
public sealed record RecordActualRequest([Required] decimal ActualAmount, decimal? ActualCost = null);
public sealed record CreateMilestoneRequest([Required] long ProjectId,
[Required] DateTime DueAt,
@@ -18,4 +18,6 @@ public sealed record CreateIndicatorRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Name,
[Required] UnitOfMeasure UnitOfMeasure,
[Required] decimal BaselineAmount,
[Required] decimal TargetAmount);
[Required] decimal TargetAmount,
long? ActivityId = null,
decimal TargetCost = 0);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
@@ -12,6 +12,9 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
public async Task<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateActivityAsync(request, userId, cancellationToken);
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.UpdateActivityAsync(request, userId, cancellationToken);
public async Task<Result> CreateBeneficiaryAsync(CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await service.CreateBeneficiaryAsync(request, userId, cancellationToken);
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
@@ -123,6 +123,29 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.New);
var approvedBudget = await context.Budgets
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
.SumAsync(b => b.Amount, cancellationToken);
if (approvedBudget == 0)
{
approvedBudget = await context.Awards
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
}
if (approvedBudget > 0)
{
var existingTotal = await context.Activities
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
if (existingTotal + request.Amount > approvedBudget)
{
return Result.Fail(CreateError.Create($"Activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, already allocated: R {existingTotal:N2}, remaining: R {(approvedBudget - existingTotal):N2}."));
}
}
context.Activities.Add(new Domain.Entities.Activity
{
ProjectId = request.ProjectId,
@@ -141,6 +164,54 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
CreateError.Create("No record was created."));
}));
public async Task<Result> UpdateActivityAsync(UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result>>)(async () =>
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var activity = await context.Activities.FirstOrDefaultAsync(a => a.Id == request.ActivityId, cancellationToken);
if (activity is null)
return Result.Fail(NotFoundError.Create("Activity not found"));
if (request.MilestoneId.HasValue && !await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail(NotFoundError.Create("Milestone not found"));
var approvedBudget = await context.Budgets
.Where(b => b.ProjectId == request.ProjectId && (b.Status == ApprovalStatus.Approved || b.Status == ApprovalStatus.Pending))
.SumAsync(b => b.Amount, cancellationToken);
if (approvedBudget == 0)
{
approvedBudget = await context.Awards
.Where(a => a.ProjectId == request.ProjectId)
.SumAsync(a => a.Amount, cancellationToken);
}
if (approvedBudget > 0)
{
var otherTotal = await context.Activities
.Where(a => a.ProjectId == request.ProjectId && a.Id != request.ActivityId)
.SumAsync(a => a.Amount, cancellationToken);
if (otherTotal + request.Amount > approvedBudget)
{
return Result.Fail(UpdateError.Create($"Updated activity budget R {request.Amount:N2} exceeds the available project budget. Approved budget: R {approvedBudget:N2}, other allocated: R {otherTotal:N2}, remaining: R {(approvedBudget - otherTotal):N2}."));
}
}
activity.Title = request.Title.Humanize(LetterCasing.Title).Trim();
activity.Description = request.Description;
activity.Category = request.Category.Humanize(LetterCasing.Title).Trim();
activity.Amount = request.Amount;
activity.MilestoneId = request.MilestoneId;
activity.PerformedAt = request.PerformedAt;
activity.UpdatedBy = userId;
activity.UpdatedAt = DateTime.UtcNow;
return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0,
UpdateError.Create("No changes detected, update cancelled."));
}));
public async Task<Result<Milestone[]>> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Milestone[]>>>)(async () =>
{
@@ -1,4 +1,4 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Services.Projects;
@@ -106,3 +106,12 @@ public sealed record CreateActivityRequest([Required] long ProjectId,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt);
public sealed record UpdateActivityRequest([Required] long ActivityId,
[Required] long ProjectId,
long? MilestoneId,
[Required][MaxLength(LabelLength)] string Category,
[Required] decimal Amount,
[Required][MaxLength(LabelLength)] string Title,
[Required][MaxLength(TextLength)] string Description,
DateTime? PerformedAt);