diff --git a/Voltiq.slnx b/Voltiq.slnx
index 9b98f53..55f1a76 100644
--- a/Voltiq.slnx
+++ b/Voltiq.slnx
@@ -1,15 +1,16 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs
index 2075d5f..5ce7a0c 100644
--- a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs
+++ b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs
@@ -7,6 +7,7 @@
using Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;
using Voltiq.Application.Features.Budgets.Commands.ApproveBudget;
using Voltiq.Application.Features.Budgets.Commands.RejectBudget;
+using Voltiq.Application.Features.Budgets.Commands.GenerateBudgetPdf;
using Voltiq.Application.Features.Budgets.Queries.GetBudgetById;
using Voltiq.Application.Features.Budgets.Queries.GetBudgets;
using Voltiq.Application.Mappings.Budgets;
@@ -90,7 +91,7 @@ public async Task Update(
/// Finalizes a budget, making it read-only.
[HttpPut("{id:guid}/finalize")]
- [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -100,7 +101,22 @@ public async Task Finalize(
{
var result = await Sender.Send(new FinalizeBudgetCommand(id), cancellationToken);
- return result.Match(_ => NoContent(), ToErrorResult);
+ return result.Match(_ => Accepted(), ToErrorResult);
+ }
+
+ /// Generates/Regenerates the PDF for a finalized budget.
+ [HttpPost("{id:guid}/generate-pdf")]
+ [ProducesResponseType(StatusCodes.Status202Accepted)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GeneratePdf(
+ Guid id,
+ CancellationToken cancellationToken)
+ {
+ var result = await Sender.Send(new GenerateBudgetPdfCommand(id), cancellationToken);
+
+ return result.Match(_ => Accepted(), ToErrorResult);
}
/// Approves a finalized budget.
diff --git a/src/Voltiq.API/docker-compose.yml b/src/Voltiq.API/docker-compose.yml
index 8c75b89..6cd2aaa 100644
--- a/src/Voltiq.API/docker-compose.yml
+++ b/src/Voltiq.API/docker-compose.yml
@@ -2,6 +2,7 @@ services:
db:
image: postgres:17-alpine
container_name: voltiq-db
+ restart: unless-stopped
environment:
POSTGRES_DB: VoltiqDb
POSTGRES_USER: postgres
@@ -11,5 +12,18 @@ services:
volumes:
- voltiq-db-data:/var/lib/postgresql/data
+ azurite:
+ image: mcr.microsoft.com/azure-storage/azurite
+ container_name: voltiq-azurite
+ restart: unless-stopped
+ command: azurite --skipApiVersionCheck --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0
+ ports:
+ - "10000:10000" # Blob service
+ - "10001:10001" # Queue service
+ - "10002:10002" # Table service
+ volumes:
+ - voltiq-azurite-data:/data
+
volumes:
voltiq-db-data:
+ voltiq-azurite-data:
diff --git a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs
index 8a38daf..07d0487 100644
--- a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs
+++ b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs
@@ -5,6 +5,7 @@ namespace Voltiq.Application.Features.Budgets;
public sealed record BudgetSummaryResponse(
Guid Id,
BudgetStatus Status,
+ PdfGenerationStatus? PdfGenerationStatus,
decimal TotalAmount,
DateTime CreatedAt,
BudgetClientSummaryResponse Client);
@@ -14,6 +15,7 @@ public sealed record BudgetClientSummaryResponse(Guid Id, string Name);
public sealed record BudgetDetailResponse(
Guid Id,
BudgetStatus Status,
+ PdfGenerationStatus? PdfGenerationStatus,
decimal TotalAmount,
DateTime CreatedAt,
BudgetClientDetailResponse Client,
diff --git a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs
index b5fded0..3e115b8 100644
--- a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs
+++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs
@@ -4,7 +4,7 @@
namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;
-public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest>
+public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest>
{
public Guid UserId { get; set; }
}
diff --git a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs
index 545df09..57748e4 100644
--- a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs
+++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs
@@ -1,5 +1,6 @@
using ErrorOr;
using MediatR;
+using Voltiq.Application.Common.Interfaces.Queue;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;
@@ -8,10 +9,11 @@ namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget;
public sealed class FinalizeBudgetCommandHandler(
IBudgetUpdateOnlyRepository budgetUpdateOnly,
- IUnitOfWork unitOfWork)
- : IRequestHandler>
+ IUnitOfWork unitOfWork,
+ IQueueService queueService)
+ : IRequestHandler>
{
- public async Task> Handle(
+ public async Task> Handle(
FinalizeBudgetCommand command, CancellationToken cancellationToken)
{
var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync(
@@ -24,6 +26,9 @@ public async Task> Handle(
await unitOfWork.SaveChangesAsync(cancellationToken);
- return Result.Updated;
+ var message = new { BudgetId = budget.Id };
+ await queueService.SendMessageAsync("budget-reports", message, cancellationToken);
+
+ return Result.Success;
}
}
diff --git a/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs
index d7e3a2c..57ea978 100644
--- a/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs
+++ b/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs
@@ -1,33 +1,33 @@
using ErrorOr;
using MediatR;
+using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Common.Interfaces.Queue;
+using Voltiq.Domain.Enums;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Exceptions.Resources;
namespace Voltiq.Application.Features.Budgets.Commands.GenerateBudgetPdf;
-public record GenerateBudgetPdfCommand(Guid BudgetId) : IRequest>;
-
-public class GenerateBudgetPdfCommandHandler : IRequestHandler>
+public record GenerateBudgetPdfCommand(Guid BudgetId) : IAuthenticatedRequest>
{
- private readonly IQueueService _queueService;
- private readonly IBudgetReadOnlyRepository _budgetRepository;
-
- public GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository)
- {
- _queueService = queueService;
- _budgetRepository = budgetRepository;
- }
+ public Guid UserId { get; set; }
+}
+public class GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository)
+ : IRequestHandler>
+{
public async Task> Handle(GenerateBudgetPdfCommand request, CancellationToken cancellationToken)
{
- var budget = await _budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken);
+ var budget = await budgetRepository.GetByIdAndUserIdAsync(request.BudgetId, request.UserId, cancellationToken);
if (budget == null)
- return Error.NotFound(description: ResourceErrorMessages.TITULO_NAO_ENCONTRADO);
+ return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO);
+
+ if (budget.Status != BudgetStatus.Finalized)
+ return Error.Validation(description: ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);
var message = new { request.BudgetId };
- await _queueService.SendMessageAsync("budget-reports", message, cancellationToken);
+ await queueService.SendMessageAsync("budget-reports", message, cancellationToken);
return Result.Success;
}
diff --git a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs
index 0fe15f6..1502785 100644
--- a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs
+++ b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs
@@ -31,14 +31,14 @@ public UpdateBudgetCommand ToCommand(Guid id) =>
extension(Budget budget)
{
public BudgetSummaryResponse ToSummaryResponse() =>
- new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt,
+ new(budget.Id, budget.Status, budget.PdfGenerationStatus, budget.TotalAmount, budget.CreatedAt,
new BudgetClientSummaryResponse(budget.Client!.Id, budget.Client!.Name));
public BudgetDetailResponse ToDetailResponse() =>
budget.ToDetailResponse(budget.Client!);
public BudgetDetailResponse ToDetailResponse(Client client) =>
- new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt,
+ new(budget.Id, budget.Status, budget.PdfGenerationStatus, budget.TotalAmount, budget.CreatedAt,
new BudgetClientDetailResponse(
client.Id, client.Name, client.Phone, client.Email.Value),
budget.Items.Select(i => new BudgetItemResponse(
diff --git a/src/Voltiq.Domain/Entities/Budget.cs b/src/Voltiq.Domain/Entities/Budget.cs
index 65108c5..2fedc8a 100644
--- a/src/Voltiq.Domain/Entities/Budget.cs
+++ b/src/Voltiq.Domain/Entities/Budget.cs
@@ -12,6 +12,7 @@ public sealed class Budget : AuditableEntity
public decimal TotalAmount { get; private set; }
public BudgetStatus Status { get; private set; }
public string? PdfUrl { get; private set; }
+ public PdfGenerationStatus? PdfGenerationStatus { get; private set; }
private readonly List _items = [];
public IReadOnlyCollection Items => _items.AsReadOnly();
@@ -82,24 +83,62 @@ public void FinalizeBudget()
throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS);
Status = BudgetStatus.Finalized;
+ PdfGenerationStatus = Enums.PdfGenerationStatus.Pending;
AddDomainEvent(new BudgetFinalizedEvent(Id));
}
public void Approve()
{
- if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated)
+ if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO);
+ if (PdfGenerationStatus != Enums.PdfGenerationStatus.Success)
+ throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL);
+
Status = BudgetStatus.Approved;
AddDomainEvent(new BudgetApprovedEvent(Id));
}
public void Reject()
{
- if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated)
+ if (Status != BudgetStatus.Finalized)
throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO);
+ if (PdfGenerationStatus != Enums.PdfGenerationStatus.Success)
+ throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL);
+
Status = BudgetStatus.Rejected;
AddDomainEvent(new BudgetRejectedEvent(Id));
}
+
+ public void StartPdfProcessing()
+ {
+ if (Status != BudgetStatus.Finalized)
+ throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);
+
+ PdfGenerationStatus = Enums.PdfGenerationStatus.Processing;
+ AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Processing));
+ }
+
+ public void SetPdfGenerationSuccess(string pdfUrl)
+ {
+ if (Status != BudgetStatus.Finalized)
+ throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);
+
+ if (string.IsNullOrWhiteSpace(pdfUrl))
+ throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_URL_OBRIGATORIA);
+
+ PdfUrl = pdfUrl;
+ PdfGenerationStatus = Enums.PdfGenerationStatus.Success;
+ AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Success));
+ }
+
+ public void SetPdfGenerationFailed()
+ {
+ if (Status != BudgetStatus.Finalized)
+ throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF);
+
+ PdfGenerationStatus = Enums.PdfGenerationStatus.Failed;
+ AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Failed));
+ }
}
diff --git a/src/Voltiq.Domain/Enums/BudgetStatus.cs b/src/Voltiq.Domain/Enums/BudgetStatus.cs
index 37e3ccc..35ce13e 100644
--- a/src/Voltiq.Domain/Enums/BudgetStatus.cs
+++ b/src/Voltiq.Domain/Enums/BudgetStatus.cs
@@ -4,7 +4,6 @@ public enum BudgetStatus
{
Draft = 1,
Finalized = 2,
- PdfGenerated = 3,
- Approved = 4,
- Rejected = 5,
+ Approved = 3,
+ Rejected = 4
}
diff --git a/src/Voltiq.Domain/Enums/PdfGenerationStatus.cs b/src/Voltiq.Domain/Enums/PdfGenerationStatus.cs
new file mode 100644
index 0000000..19f8eab
--- /dev/null
+++ b/src/Voltiq.Domain/Enums/PdfGenerationStatus.cs
@@ -0,0 +1,9 @@
+namespace Voltiq.Domain.Enums;
+
+public enum PdfGenerationStatus
+{
+ Pending = 1,
+ Processing = 2,
+ Success = 3,
+ Failed = 4
+}
diff --git a/src/Voltiq.Domain/Events/BudgetPdfGenerationStatusChangedEvent.cs b/src/Voltiq.Domain/Events/BudgetPdfGenerationStatusChangedEvent.cs
new file mode 100644
index 0000000..713d7f0
--- /dev/null
+++ b/src/Voltiq.Domain/Events/BudgetPdfGenerationStatusChangedEvent.cs
@@ -0,0 +1,5 @@
+using Voltiq.Domain.Enums;
+
+namespace Voltiq.Domain.Events;
+
+public sealed record BudgetPdfGenerationStatusChangedEvent(Guid BudgetId, PdfGenerationStatus Status) : BaseDomainEvent;
diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs
index 87526a7..81688a6 100644
--- a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs
+++ b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs
@@ -8,5 +8,7 @@ public interface IBudgetUpdateOnlyRepository
Task GetTrackedByIdWithItemsAndUserIdAsync(Guid id, Guid userId,
CancellationToken cancellationToken = default);
+ Task GetTrackedByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
void Remove(Entities.Budget entity);
}
diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs
index 934b31b..88b0c1e 100644
--- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs
+++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs
@@ -447,7 +447,7 @@ public static string ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO {
}
///
- /// Looks up a localized string similar to O status atual do orçamento não permite aprovação. Ele deve estar finalizado ou com PDF gerado..
+ /// Looks up a localized string similar to O status atual do orçamento não permite aprovação. Ele deve estar finalizado..
///
public static string ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO {
get {
@@ -456,7 +456,7 @@ public static string ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO {
}
///
- /// Looks up a localized string similar to O status atual do orçamento não permite rejeição. Ele deve estar finalizado ou com PDF gerado..
+ /// Looks up a localized string similar to O status atual do orçamento não permite rejeição. Ele deve estar finalizado..
///
public static string ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO {
get {
@@ -464,6 +464,33 @@ public static string ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO {
}
}
+ ///
+ /// Looks up a localized string similar to O status atual do orçamento não permite a geração do PDF. Ele deve estar finalizado..
+ ///
+ public static string ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF {
+ get {
+ return ResourceManager.GetString("ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to A URL do PDF do orçamento é obrigatória..
+ ///
+ public static string ORCAMENTO_PDF_URL_OBRIGATORIA {
+ get {
+ return ResourceManager.GetString("ORCAMENTO_PDF_URL_OBRIGATORIA", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to O PDF do orçamento ainda não foi gerado ou não está disponível para aprovação/rejeição..
+ ///
+ public static string ORCAMENTO_PDF_NAO_DISPONIVEL {
+ get {
+ return ResourceManager.GetString("ORCAMENTO_PDF_NAO_DISPONIVEL", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to O usuário do orçamento é obrigatório..
///
diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx
index d6e4fc8..0065767 100644
--- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx
+++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx
@@ -242,10 +242,19 @@
Apenas orçamentos em rascunho podem ser finalizados.
- O status atual do orçamento não permite aprovação. Ele deve estar finalizado ou com PDF gerado.
+ O status atual do orçamento não permite aprovação. Ele deve estar finalizado.
- O status atual do orçamento não permite rejeição. Ele deve estar finalizado ou com PDF gerado.
+ O status atual do orçamento não permite rejeição. Ele deve estar finalizado.
+
+
+ O status atual do orçamento não permite a geração do PDF. Ele deve estar finalizado.
+
+
+ A URL do PDF do orçamento é obrigatória.
+
+
+ O PDF do orçamento ainda não foi gerado ou não está disponível para aprovação/rejeição.
diff --git a/src/Voltiq.Functions/GenerateReportFunction.cs b/src/Voltiq.Functions/GenerateReportFunction.cs
index cd828ae..00596bb 100644
--- a/src/Voltiq.Functions/GenerateReportFunction.cs
+++ b/src/Voltiq.Functions/GenerateReportFunction.cs
@@ -56,13 +56,16 @@ public async Task Run([QueueTrigger("budget-reports")] string message, Cancellat
return;
}
- var budget = await _budgetRepository.GetByIdAsync(msg.BudgetId, cancellationToken);
+ var budget = await _budgetUpdateRepository.GetTrackedByIdAsync(msg.BudgetId, cancellationToken);
if (budget == null)
{
_logger.LogWarning("Budget not found for ID: {BudgetId}", msg.BudgetId);
return;
}
+ budget.StartPdfProcessing();
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
var client = await _clientRepository.GetByIdAndUserIdAsync(budget.ClientId, budget.UserId, cancellationToken);
var reportData = new BudgetReportData
@@ -74,19 +77,28 @@ public async Task Run([QueueTrigger("budget-reports")] string message, Cancellat
CreatedAt = budget.CreatedAt
};
- _logger.LogInformation("Generating PDF for Budget ID: {BudgetId}", budget.Id);
- var pdfBytes = await _reportGenerator.GenerateAsync(reportData, cancellationToken);
+ try
+ {
+ _logger.LogInformation("Generating PDF for Budget ID: {BudgetId}", budget.Id);
+ var pdfBytes = await _reportGenerator.GenerateAsync(reportData, cancellationToken);
- var fileName = $"budget-{budget.Id}.pdf";
-
- _logger.LogInformation("Uploading PDF for Budget ID: {BudgetId} to Blob Storage", budget.Id);
- var uri = await _storageService.UploadAsync(fileName, pdfBytes, "application/pdf", cancellationToken);
+ var fileName = $"budget-{budget.Id}.pdf";
- budget.MarkAsPdfGenerated(uri);
- _budgetUpdateRepository.Update(budget);
- await _unitOfWork.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation("Uploading PDF for Budget ID: {BudgetId} to Blob Storage", budget.Id);
+ var uri = await _storageService.UploadAsync(fileName, pdfBytes, "application/pdf", cancellationToken);
- _logger.LogInformation("Report generated and budget status updated successfully. URI: {Uri}", uri);
+ budget.SetPdfGenerationSuccess(uri);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation("Report generated and budget status updated successfully. URI: {Uri}", uri);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error generating report for budget ID: {BudgetId}", budget.Id);
+ budget.SetPdfGenerationFailed();
+ await _unitOfWork.SaveChangesAsync(CancellationToken.None);
+ throw;
+ }
}
catch (Exception ex)
{
diff --git a/src/Voltiq.Infrastructure/DependencyInjection.cs b/src/Voltiq.Infrastructure/DependencyInjection.cs
index 84f5077..e203bb0 100644
--- a/src/Voltiq.Infrastructure/DependencyInjection.cs
+++ b/src/Voltiq.Infrastructure/DependencyInjection.cs
@@ -6,6 +6,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Voltiq.Application.Common.Interfaces;
+using Voltiq.Application.Common.Interfaces.Storage;
+using Voltiq.Application.Common.Interfaces.Queue;
+using Voltiq.Application.Common.Interfaces.Reports;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Budget;
using Voltiq.Domain.Interfaces.Repositories.Client;
@@ -35,6 +38,14 @@ public static void AddInfrastructure(this IServiceCollection services,
AddJwtAuthentication(services, configuration);
AddAuthServices(services);
AddCryptography(services);
+ AddExternalServices(services);
+ }
+
+ private static void AddExternalServices(IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
}
private static void AddCryptography(IServiceCollection services)
diff --git a/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.Designer.cs b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.Designer.cs
new file mode 100644
index 0000000..302d74f
--- /dev/null
+++ b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.Designer.cs
@@ -0,0 +1,460 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Voltiq.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Voltiq.Infrastructure.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260625030604_AddPdfGenerationStatusToBudget")]
+ partial class AddPdfGenerationStatusToBudget
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClientId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("PdfGenerationStatus")
+ .HasColumnType("integer");
+
+ b.Property("PdfUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TotalAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId")
+ .HasDatabaseName("IX_Budgets_ClientId");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_Budgets_IsDeleted");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("IX_Budgets_Status");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_Budgets_UserId");
+
+ b.ToTable("Budgets");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.BudgetItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BudgetId")
+ .HasColumnType("uuid");
+
+ b.Property("MaterialId")
+ .HasColumnType("uuid");
+
+ b.Property("MaterialName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Quantity")
+ .HasColumnType("integer");
+
+ b.Property("TotalPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("Unit")
+ .HasColumnType("integer");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId")
+ .HasDatabaseName("IX_BudgetItems_BudgetId");
+
+ b.HasIndex("MaterialId")
+ .HasDatabaseName("IX_BudgetItems_MaterialId");
+
+ b.ToTable("BudgetItem");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Client", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("Email");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_Clients_IsDeleted");
+
+ b.HasIndex("UserId", "Email")
+ .IsUnique()
+ .HasDatabaseName("IX_Clients_UserId_Email");
+
+ b.ToTable("Clients");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Material", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DefaultPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Unit")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsActive")
+ .HasDatabaseName("IX_Materials_IsActive");
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_Materials_IsDeleted");
+
+ b.HasIndex("Name")
+ .HasDatabaseName("IX_Materials_Name");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_Materials_UserId");
+
+ b.ToTable("Materials");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsRevoked")
+ .HasColumnType("boolean");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Document")
+ .IsRequired()
+ .HasMaxLength(14)
+ .HasColumnType("character varying(14)")
+ .HasColumnName("Document");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("Email");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Document")
+ .IsUnique();
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("IsDeleted")
+ .HasDatabaseName("IX_Users_IsDeleted");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b =>
+ {
+ b.HasOne("Voltiq.Domain.Entities.Client", "Client")
+ .WithMany()
+ .HasForeignKey("ClientId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Voltiq.Domain.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Client");
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.BudgetItem", b =>
+ {
+ b.HasOne("Voltiq.Domain.Entities.Budget", null)
+ .WithMany("Items")
+ .HasForeignKey("BudgetId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Voltiq.Domain.Entities.Material", null)
+ .WithMany()
+ .HasForeignKey("MaterialId")
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Client", b =>
+ {
+ b.HasOne("Voltiq.Domain.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.OwnsOne("Voltiq.Domain.ValueObjects.Address", "Address", b1 =>
+ {
+ b1.Property("ClientId")
+ .HasColumnType("uuid");
+
+ b1.Property("City")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("City");
+
+ b1.Property("Number")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("Number");
+
+ b1.Property("State")
+ .IsRequired()
+ .HasMaxLength(2)
+ .HasColumnType("character varying(2)")
+ .HasColumnName("State");
+
+ b1.Property("Street")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("Street");
+
+ b1.Property("ZipCode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("ZipCode");
+
+ b1.HasKey("ClientId");
+
+ b1.ToTable("Clients");
+
+ b1.WithOwner()
+ .HasForeignKey("ClientId");
+ });
+
+ b.Navigation("Address")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Material", b =>
+ {
+ b.HasOne("Voltiq.Domain.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.RefreshToken", b =>
+ {
+ b.HasOne("Voltiq.Domain.Entities.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b =>
+ {
+ b.Navigation("Items");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.cs b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.cs
new file mode 100644
index 0000000..a9cafed
--- /dev/null
+++ b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Voltiq.Infrastructure.Migrations
+{
+ ///
+ public partial class AddPdfGenerationStatusToBudget : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "PdfGenerationStatus",
+ table: "Budgets",
+ type: "integer",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "PdfGenerationStatus",
+ table: "Budgets");
+ }
+ }
+}
diff --git a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
index 594bb09..9d5c7f4 100644
--- a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -46,6 +46,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("boolean")
.HasDefaultValue(false);
+ b.Property("PdfGenerationStatus")
+ .HasColumnType("integer");
+
b.Property("PdfUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
diff --git a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs
index 92421d7..07b93eb 100644
--- a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs
+++ b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Voltiq.Domain.Entities;
+using Voltiq.Domain.Enums;
namespace Voltiq.Infrastructure.Persistence.Configurations;
@@ -22,6 +23,10 @@ public void Configure(EntityTypeBuilder builder)
.IsRequired()
.HasConversion();
+ builder.Property(b => b.PdfGenerationStatus)
+ .HasConversion()
+ .IsRequired(false);
+
builder.Property(b => b.PdfUrl)
.HasMaxLength(2048);
diff --git a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs
index 73d946d..e63a2e8 100644
--- a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs
+++ b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs
@@ -78,6 +78,13 @@ public void Remove(Domain.Entities.Budget entity)
.FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken);
}
+ public async Task GetTrackedByIdAsync(
+ Guid id, CancellationToken cancellationToken = default)
+ {
+ return await context.Budgets
+ .FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
+ }
+
public async Task AddAsync(Domain.Entities.Budget entity,
CancellationToken cancellationToken = default)
{
diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs
index c099781..dd4f107 100644
--- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs
+++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs
@@ -34,6 +34,8 @@ public async Task Handle_WithFinalizedBudget_ShouldApproveAndSave()
var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm");
budget.AddItem(item);
budget.FinalizeBudget();
+ budget.StartPdfProcessing();
+ budget.SetPdfGenerationSuccess("https://storage.voltiq.com/budgets/budget-123.pdf");
_budgetUpdateRepoMock
.Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny()))
diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs
index fb8ca6d..f6488c3 100644
--- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs
+++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs
@@ -14,6 +14,7 @@ public class FinalizeBudgetCommandHandlerTests
{
private readonly Mock _budgetUpdateRepoMock = new();
private readonly Mock _unitOfWorkMock = new();
+ private readonly Mock _queueServiceMock = new();
private readonly Guid _userId = Guid.NewGuid();
private readonly Guid _budgetId = Guid.NewGuid();
@@ -23,11 +24,12 @@ private FinalizeBudgetCommandHandler CreateHandler()
{
return new FinalizeBudgetCommandHandler(
_budgetUpdateRepoMock.Object,
- _unitOfWorkMock.Object);
+ _unitOfWorkMock.Object,
+ _queueServiceMock.Object);
}
[Fact]
- public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSave()
+ public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSaveAndQueueMessage()
{
// Arrange
var budget = Budget.Register(_userId, _clientId);
@@ -46,11 +48,13 @@ public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSave()
// Assert
result.IsError.ShouldBeFalse();
- result.Value.ShouldBe(Result.Updated);
+ result.Value.ShouldBe(Result.Success);
budget.Status.ShouldBe(BudgetStatus.Finalized);
+ budget.PdfGenerationStatus.ShouldBe(PdfGenerationStatus.Pending);
_unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once);
+ _queueServiceMock.Verify(q => q.SendMessageAsync("budget-reports", It.IsAny