diff --git a/.gitignore b/.gitignore index 57ab0a2..17ef8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -49,13 +49,6 @@ nuget.config .vs/ *.rsuser -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - # OS .DS_Store Thumbs.db @@ -69,12 +62,8 @@ appsettings.Production.json # Logs logs/ + # Copilot instructions .github/copilot-instructions.md -# Squad: ignore runtime state (logs, inbox, sessions) -.squad/orchestration-log/ -.squad/log/ -.squad/decisions/inbox/ -.squad/sessions/ -# Squad: SubSquad activation file (local to this machine) -.squad-workstream +.agents +.antigravitycli diff --git a/README.md b/README.md index d556cef..6e2c09b 100644 --- a/README.md +++ b/README.md @@ -460,8 +460,69 @@ Cria um novo usuário na plataforma. - Documento deve ser único no sistema. - Senha é armazenada como hash **Argon2id** — nunca em texto puro. +### Orçamentos + +#### `PUT /api/v1/budgets/{id}/finalize` — Finalizar orçamento + +Muda o status do orçamento de `Draft` para `Finalized`. O orçamento deve ter pelo menos um item e pertencer ao usuário autenticado. Uma vez finalizado, o orçamento torna-se somente leitura e não pode mais ser editado. + +**Headers obrigatórios:** +``` +Authorization: Bearer +``` + +**Respostas:** + +| Status | Descrição | +|---|---| +| `204 NoContent` | Orçamento finalizado com sucesso | +| `400 Bad Request` | Orçamento não está em rascunho ou não contém itens (lança DomainException) | +| `401 Unauthorized` | Token ausente ou inválido | +| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário | + --- +#### `PUT /api/v1/budgets/{id}/approve` — Aprovar orçamento + +Aprova um orçamento que foi finalizado ou tem PDF gerado. + +**Headers obrigatórios:** +``` +Authorization: Bearer +``` + +**Respostas:** + +| Status | Descrição | +|---|---| +| `204 NoContent` | Orçamento aprovado com sucesso | +| `400 Bad Request` | Orçamento não está nos estados `Finalized` ou `PdfGenerated` (lança DomainException) | +| `401 Unauthorized` | Token ausente ou inválido | +| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário | + +--- + +#### `PUT /api/v1/budgets/{id}/reject` — Rejeitar orçamento + +Rejeita um orçamento que foi finalizado ou tem PDF gerado. + +**Headers obrigatórios:** +``` +Authorization: Bearer +``` + +**Respostas:** + +| Status | Descrição | +|---|---| +| `204 NoContent` | Orçamento rejeitado com sucesso | +| `400 Bad Request` | Orçamento não está nos estados `Finalized` ou `PdfGenerated` (lança DomainException) | +| `401 Unauthorized` | Token ausente ou inválido | +| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário | + +--- + + ### CQRS com MediatR diff --git a/scripts/seed-dev.cs b/scripts/seed-dev.cs index 15efd0d..9224426 100644 --- a/scripts/seed-dev.cs +++ b/scripts/seed-dev.cs @@ -118,19 +118,19 @@ // Orçamento 1: Construtora ABC — mix de material vinculado + item customizado var budget1 = Budget.Register(user.Id, clients[0].Id); -budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, "Cabo Flexível 2,5mm", - MaterialUnit.Metro, 50, 4.80m)); -budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, "Tomada 2P+T", MaterialUnit.Unidade, - 8, 18.50m)); -budget1.AddItem(BudgetItem.Create(budget1.Id, null, "Mão de obra elétrica", null, 1, 350.00m)); +budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, BudgetItemType.Material, + MaterialUnit.Metro, 50, 4.80m, "Cabo Flexível 2,5mm")); +budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, BudgetItemType.Material, MaterialUnit.Unidade, + 8, 18.50m, "Tomada 2P+T")); +budget1.AddItem(BudgetItem.Create(budget1.Id, null, BudgetItemType.MaoDeObra, null, 1, 350.00m, "Mão de obra elétrica")); await db.Budgets.AddAsync(budget1); await db.SaveChangesAsync(); // Orçamento 2: João da Silva — item customizado var budget2 = Budget.Register(user.Id, clients[1].Id); -budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, "Disjuntor 20A", - MaterialUnit.Unidade, 2, 32.00m)); +budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, BudgetItemType.Material, + MaterialUnit.Unidade, 2, 32.00m, "Disjuntor 20A")); await db.Budgets.AddAsync(budget2); await db.SaveChangesAsync(); diff --git a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs index 45e8cc0..2075d5f 100644 --- a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs +++ b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs @@ -3,6 +3,10 @@ using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Commands.DeleteBudget; using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; +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.Queries.GetBudgetById; using Voltiq.Application.Features.Budgets.Queries.GetBudgets; using Voltiq.Application.Mappings.Budgets; @@ -67,4 +71,65 @@ public async Task Delete( return result.Match(_ => NoContent(), ToErrorResult); } + + /// Updates a budget (must belong to the authenticated user). + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update( + Guid id, + [FromBody] UpdateBudgetRequest request, + CancellationToken cancellationToken) + { + var result = await Sender.Send(request.ToCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Finalizes a budget, making it read-only. + [HttpPut("{id:guid}/finalize")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Finalize( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new FinalizeBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Approves a finalized budget. + [HttpPut("{id:guid}/approve")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Approve( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new ApproveBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Rejects a finalized budget. + [HttpPut("{id:guid}/reject")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Reject( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new RejectBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } } diff --git a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs index 8b9fb86..8a38daf 100644 --- a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs +++ b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs @@ -25,6 +25,7 @@ public sealed record BudgetItemResponse( Guid Id, Guid? MaterialId, string MaterialName, + BudgetItemType Type, MaterialUnit? Unit, int Quantity, decimal UnitPrice, diff --git a/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs new file mode 100644 index 0000000..16e2cfa --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs @@ -0,0 +1,10 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.ApproveBudget; + +public record ApproveBudgetCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs new file mode 100644 index 0000000..af405f9 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.ApproveBudget; + +public sealed class ApproveBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + ApproveBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budget.Approve(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs new file mode 100644 index 0000000..b5fded0 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs @@ -0,0 +1,10 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; + +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 new file mode 100644 index 0000000..545df09 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; + +public sealed class FinalizeBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + FinalizeBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budget.FinalizeBudget(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs index 63299c2..abb0e8b 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs @@ -14,6 +14,7 @@ public sealed record RegisterBudgetCommand( public sealed record RegisterBudgetItemCommand( Guid? MaterialId, string MaterialName, + BudgetItemType Type, MaterialUnit? Unit, int Quantity, decimal UnitPrice); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs index 553f095..a88a14e 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs @@ -43,8 +43,7 @@ public async Task> Handle( foreach (var item in command.Items) { var budgetItem = BudgetItem.Create( - budget.Id, item.MaterialId, item.MaterialName, - item.Unit, item.Quantity, item.UnitPrice); + budget.Id, item.MaterialId, item.Type, item.Unit, item.Quantity, item.UnitPrice, item.MaterialName); budget.AddItem(budgetItem); } diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs index e8a7f63..a6aa842 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Voltiq.Domain.Enums; using Voltiq.Exceptions.Resources; namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget; @@ -21,6 +22,30 @@ public RegisterBudgetCommandValidator() .NotEmpty() .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + item.RuleFor(i => i.Type) + .IsInEnum() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_TIPO_INVALIDO); + + item.RuleFor(i => i.MaterialId) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + + item.RuleFor(i => i.MaterialId) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO); + + item.RuleFor(i => i.Unit) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + + item.RuleFor(i => i.Unit) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA); + item.RuleFor(i => i.Quantity) .GreaterThan(0) .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs index 8fa9a2b..d993067 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs @@ -9,6 +9,7 @@ public sealed record RegisterBudgetRequest( public sealed record RegisterBudgetItemRequest( Guid? MaterialId, string MaterialName, + BudgetItemType Type, MaterialUnit? Unit, int Quantity, decimal UnitPrice); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs new file mode 100644 index 0000000..b528f7b --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs @@ -0,0 +1,10 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.RejectBudget; + +public record RejectBudgetCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs new file mode 100644 index 0000000..8bfa018 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.RejectBudget; + +public sealed class RejectBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + RejectBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budget.Reject(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs new file mode 100644 index 0000000..70e8cf3 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs @@ -0,0 +1,21 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed record UpdateBudgetCommand( + Guid Id, + Guid ClientId, + IReadOnlyList Items) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} + +public sealed record UpdateBudgetItemCommand( + Guid? MaterialId, + string MaterialName, + BudgetItemType Type, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs new file mode 100644 index 0000000..bfd908b --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs @@ -0,0 +1,58 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed class UpdateBudgetCommandHandler( + IClientReadOnlyRepository clientReadOnly, + IMaterialReadOnlyRepository materialReadOnly, + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + UpdateBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + var client = await clientReadOnly.GetByIdAndUserIdAsync( + command.ClientId, command.UserId, cancellationToken); + + if (client is null) + return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); + + var budgetItems = new List(); + foreach (var item in command.Items) + { + if (item.MaterialId.HasValue) + { + var material = await materialReadOnly.GetByIdAndUserIdAsync( + item.MaterialId.Value, command.UserId, cancellationToken); + + if (material is null) + return Error.NotFound(description: ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + } + + var budgetItem = BudgetItem.Create( + budget.Id, item.MaterialId, item.Type, item.Unit, item.Quantity, item.UnitPrice, item.MaterialName); + + budgetItems.Add(budgetItem); + } + + budget.Edit(command.ClientId, budgetItems); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs new file mode 100644 index 0000000..ebd7ff9 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed class UpdateBudgetCommandValidator : AbstractValidator +{ + public UpdateBudgetCommandValidator() + { + RuleFor(x => x.ClientId) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.MaterialName) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + + item.RuleFor(i => i.Type) + .IsInEnum() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_TIPO_INVALIDO); + + item.RuleFor(i => i.MaterialId) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + + item.RuleFor(i => i.MaterialId) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO); + + item.RuleFor(i => i.Unit) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + + item.RuleFor(i => i.Unit) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA); + + item.RuleFor(i => i.Quantity) + .GreaterThan(0) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + + item.RuleFor(i => i.UnitPrice) + .GreaterThan(0) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + }); + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs new file mode 100644 index 0000000..1600907 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs @@ -0,0 +1,15 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed record UpdateBudgetRequest( + Guid ClientId, + IReadOnlyList Items); + +public sealed record UpdateBudgetItemRequest( + Guid? MaterialId, + string MaterialName, + BudgetItemType Type, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice); diff --git a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs index c7fbab9..0fe15f6 100644 --- a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs +++ b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs @@ -1,5 +1,6 @@ using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; using Voltiq.Domain.Entities; namespace Voltiq.Application.Mappings.Budgets; @@ -12,7 +13,18 @@ public RegisterBudgetCommand ToCommand() => new(request.ClientId, request.Items .Select(i => new RegisterBudgetItemCommand( - i.MaterialId, i.MaterialName, i.Unit, i.Quantity, i.UnitPrice)) + i.MaterialId, i.MaterialName, i.Type, i.Unit, i.Quantity, i.UnitPrice)) + .ToList()); + } + + extension(UpdateBudgetRequest request) + { + public UpdateBudgetCommand ToCommand(Guid id) => + new(id, + request.ClientId, + request.Items + .Select(i => new UpdateBudgetItemCommand( + i.MaterialId, i.MaterialName, i.Type, i.Unit, i.Quantity, i.UnitPrice)) .ToList()); } @@ -20,17 +32,17 @@ public RegisterBudgetCommand ToCommand() => { public BudgetSummaryResponse ToSummaryResponse() => new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, - new BudgetClientSummaryResponse(budget.Client.Id, budget.Client.Name)); + new BudgetClientSummaryResponse(budget.Client!.Id, budget.Client!.Name)); public BudgetDetailResponse ToDetailResponse() => - budget.ToDetailResponse(budget.Client); + budget.ToDetailResponse(budget.Client!); public BudgetDetailResponse ToDetailResponse(Client client) => new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, new BudgetClientDetailResponse( client.Id, client.Name, client.Phone, client.Email.Value), budget.Items.Select(i => new BudgetItemResponse( - i.Id, i.MaterialId, i.MaterialName, i.Unit, + i.Id, i.MaterialId, i.MaterialName, i.Type, i.Unit, i.Quantity, i.UnitPrice, i.TotalPrice)).ToList()); } } diff --git a/src/Voltiq.Domain/Entities/Budget.cs b/src/Voltiq.Domain/Entities/Budget.cs index a262bc7..65108c5 100644 --- a/src/Voltiq.Domain/Entities/Budget.cs +++ b/src/Voltiq.Domain/Entities/Budget.cs @@ -46,8 +46,60 @@ public void AddItem(BudgetItem item) RecalculateTotals(); } + public void Edit(Guid clientId, IReadOnlyCollection items) + { + if (Status != BudgetStatus.Draft) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO); + + if (clientId == Guid.Empty) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + + if (items is null || items.Count == 0) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + ClientId = clientId; + + _items.Clear(); + foreach (var item in items) + { + _items.Add(item); + } + + RecalculateTotals(); + } + public void RecalculateTotals() { TotalAmount = _items.Sum(i => i.TotalPrice); } + + public void FinalizeBudget() + { + if (Status != BudgetStatus.Draft) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO); + + if (_items.Count == 0) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + Status = BudgetStatus.Finalized; + AddDomainEvent(new BudgetFinalizedEvent(Id)); + } + + public void Approve() + { + if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO); + + Status = BudgetStatus.Approved; + AddDomainEvent(new BudgetApprovedEvent(Id)); + } + + public void Reject() + { + if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO); + + Status = BudgetStatus.Rejected; + AddDomainEvent(new BudgetRejectedEvent(Id)); + } } diff --git a/src/Voltiq.Domain/Entities/BudgetItem.cs b/src/Voltiq.Domain/Entities/BudgetItem.cs index f932d03..d2d467d 100644 --- a/src/Voltiq.Domain/Entities/BudgetItem.cs +++ b/src/Voltiq.Domain/Entities/BudgetItem.cs @@ -9,6 +9,7 @@ public sealed class BudgetItem : BaseEntity public Guid BudgetId { get; private set; } public Guid? MaterialId { get; private set; } public string MaterialName { get; private set; } = null!; + public BudgetItemType Type { get; private set; } public MaterialUnit? Unit { get; private set; } public int Quantity { get; private set; } public decimal UnitPrice { get; private set; } @@ -19,27 +20,30 @@ private BudgetItem() { } private BudgetItem( Guid budgetId, Guid? materialId, - string materialName, + BudgetItemType type, MaterialUnit? unit, int quantity, - decimal unitPrice) + decimal unitPrice, + string materialName) { BudgetId = budgetId; MaterialId = materialId; - MaterialName = materialName; + Type = type; Unit = unit; Quantity = quantity; UnitPrice = unitPrice; TotalPrice = quantity * unitPrice; + MaterialName = materialName; } public static BudgetItem Create( Guid budgetId, Guid? materialId, - string materialName, + BudgetItemType type, MaterialUnit? unit, int quantity, - decimal unitPrice) + decimal unitPrice, + string materialName) { if (string.IsNullOrWhiteSpace(materialName)) throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); @@ -50,6 +54,23 @@ public static BudgetItem Create( if (unitPrice <= 0) throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); - return new BudgetItem(budgetId, materialId, materialName.Trim(), unit, quantity, unitPrice); + if (type == BudgetItemType.Material) + { + if (materialId is null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + + if (unit is null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + } + else + { + if (materialId is not null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO); + + if (unit is not null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA); + } + + return new BudgetItem(budgetId, materialId, type, unit, quantity, unitPrice, materialName.Trim()); } } diff --git a/src/Voltiq.Domain/Enums/BudgetItemType.cs b/src/Voltiq.Domain/Enums/BudgetItemType.cs new file mode 100644 index 0000000..82aa5f5 --- /dev/null +++ b/src/Voltiq.Domain/Enums/BudgetItemType.cs @@ -0,0 +1,8 @@ +namespace Voltiq.Domain.Enums; + +public enum BudgetItemType +{ + Material = 1, + MaoDeObra = 2, + Outros = 3, +} diff --git a/src/Voltiq.Domain/Enums/BudgetStatus.cs b/src/Voltiq.Domain/Enums/BudgetStatus.cs index 5ba5af6..37e3ccc 100644 --- a/src/Voltiq.Domain/Enums/BudgetStatus.cs +++ b/src/Voltiq.Domain/Enums/BudgetStatus.cs @@ -6,4 +6,5 @@ public enum BudgetStatus Finalized = 2, PdfGenerated = 3, Approved = 4, + Rejected = 5, } diff --git a/src/Voltiq.Domain/Events/BudgetApprovedEvent.cs b/src/Voltiq.Domain/Events/BudgetApprovedEvent.cs new file mode 100644 index 0000000..fbd8639 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetApprovedEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record BudgetApprovedEvent(Guid BudgetId) : BaseDomainEvent; diff --git a/src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs b/src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs new file mode 100644 index 0000000..a2ca6d6 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record BudgetFinalizedEvent(Guid BudgetId) : BaseDomainEvent; diff --git a/src/Voltiq.Domain/Events/BudgetRejectedEvent.cs b/src/Voltiq.Domain/Events/BudgetRejectedEvent.cs new file mode 100644 index 0000000..70c2417 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetRejectedEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record BudgetRejectedEvent(Guid BudgetId) : BaseDomainEvent; diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs index b5c14a6..dd5f7fa 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -302,6 +302,24 @@ public static string ORCAMENTO_CLIENTE_OBRIGATORIO { } } + /// + /// Looks up a localized string similar to O material deve ser nulo para este tipo de item.. + /// + public static string ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O material é obrigatório para itens do tipo Material.. + /// + public static string ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL", resourceCulture); + } + } + /// /// Looks up a localized string similar to O nome do material no item é obrigatório.. /// @@ -329,6 +347,33 @@ public static string ORCAMENTO_ITEM_QUANTIDADE_INVALIDA { } } + /// + /// Looks up a localized string similar to Tipo de item do orçamento inválido.. + /// + public static string ORCAMENTO_ITEM_TIPO_INVALIDO { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_TIPO_INVALIDO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A unidade deve ser nula para este tipo de item.. + /// + public static string ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A unidade é obrigatória para itens do tipo Material.. + /// + public static string ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL", resourceCulture); + } + } + /// /// Looks up a localized string similar to O orçamento deve conter pelo menos um item.. /// @@ -347,6 +392,42 @@ public static string ORCAMENTO_NAO_ENCONTRADO { } } + /// + /// Looks up a localized string similar to Apenas orçamentos em rascunho podem ser editados.. + /// + public static string ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO { + get { + return ResourceManager.GetString("ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Apenas orçamentos em rascunho podem ser finalizados.. + /// + public static string ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO { + get { + return ResourceManager.GetString("ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO", resourceCulture); + } + } + + /// + /// 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.. + /// + public static string ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO { + get { + return ResourceManager.GetString("ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO", resourceCulture); + } + } + + /// + /// 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.. + /// + public static string ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO { + get { + return ResourceManager.GetString("ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO", 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 69e1261..d341206 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx @@ -198,6 +198,21 @@ O preço unitário deve ser maior que zero. + + + Tipo de item do orçamento inválido. + + + O material é obrigatório para itens do tipo Material. + + + A unidade é obrigatória para itens do tipo Material. + + + O material deve ser nulo para este tipo de item. + + + A unidade deve ser nula para este tipo de item. O orçamento deve conter pelo menos um item. @@ -205,6 +220,18 @@ Orçamento não encontrado. + + Apenas orçamentos em rascunho podem ser editados. + + + 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 rejeição. Ele deve estar finalizado ou com PDF gerado. + diff --git a/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs new file mode 100644 index 0000000..580dfd7 --- /dev/null +++ b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs @@ -0,0 +1,457 @@ +// +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("20260526002045_AddBudgetItemType")] + partial class AddBudgetItemType + { + /// + 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("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/20260526002045_AddBudgetItemType.cs b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs new file mode 100644 index 0000000..2c49e81 --- /dev/null +++ b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Voltiq.Infrastructure.Migrations +{ + /// + public partial class AddBudgetItemType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "BudgetItem", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "BudgetItem"); + } + } +} diff --git a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index daa28e6..594bb09 100644 --- a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -104,6 +104,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasPrecision(18, 2) .HasColumnType("numeric(18,2)"); + b.Property("Type") + .HasColumnType("integer"); + b.Property("Unit") .HasColumnType("integer"); @@ -339,7 +342,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b => { - b.HasOne("Voltiq.Domain.Entities.Client", null) + b.HasOne("Voltiq.Domain.Entities.Client", "Client") .WithMany() .HasForeignKey("ClientId") .OnDelete(DeleteBehavior.Restrict) @@ -350,6 +353,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + + b.Navigation("Client"); }); modelBuilder.Entity("Voltiq.Domain.Entities.BudgetItem", b => diff --git a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs index d35c2af..6286cea 100644 --- a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs +++ b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs @@ -12,6 +12,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(i => i.BudgetId).IsRequired(); + builder.Property(i => i.Type) + .IsRequired() + .HasConversion(); + builder.Property(i => i.MaterialId); builder.Property(i => i.MaterialName) diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..c099781 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs @@ -0,0 +1,78 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.ApproveBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class ApproveBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private ApproveBudgetCommandHandler CreateHandler() + { + return new ApproveBudgetCommandHandler( + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithFinalizedBudget_ShouldApproveAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(item); + budget.FinalizeBudget(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new ApproveBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.Status.ShouldBe(BudgetStatus.Approved); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new ApproveBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..fb8ca6d --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class FinalizeBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private FinalizeBudgetCommandHandler CreateHandler() + { + return new FinalizeBudgetCommandHandler( + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(item); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new FinalizeBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.Status.ShouldBe(BudgetStatus.Finalized); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new FinalizeBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs index 4af50b2..9f1b323 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs @@ -1,7 +1,6 @@ using ErrorOr; using Moq; using Shouldly; -using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; using Voltiq.Domain.Entities; using Voltiq.Domain.Enums; @@ -16,26 +15,36 @@ namespace Voltiq.Application.Tests.Features.Budgets.Commands; public class RegisterBudgetCommandHandlerTests { + private readonly Mock _budgetWriteRepoMock = new(); + private readonly Guid _clientId = Guid.NewGuid(); private readonly Mock _clientReadRepoMock = new(); private readonly Mock _materialReadRepoMock = new(); - private readonly Mock _budgetWriteRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); private readonly Guid _userId = Guid.NewGuid(); - private readonly Guid _clientId = Guid.NewGuid(); - private RegisterBudgetCommandHandler CreateHandler() => - new(_clientReadRepoMock.Object, _materialReadRepoMock.Object, + private RegisterBudgetCommandHandler CreateHandler() + { + return new RegisterBudgetCommandHandler(_clientReadRepoMock.Object, + _materialReadRepoMock.Object, _budgetWriteRepoMock.Object, _unitOfWorkMock.Object); + } - private Client MakeClient() => - Client.Register(_userId, "João Silva", "(11) 99999-9999", + private Client MakeClient() + { + return Client.Register(_userId, "João Silva", "(11) 99999-9999", Email.Create("joao@example.com").Value, Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); + } - private RegisterBudgetCommand CommandWithCustomItem() => - new(_clientId, [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)]) + private RegisterBudgetCommand CommandWithCustomItem() + { + return new RegisterBudgetCommand(_clientId, [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, + 2, 15.50m) + ]) { UserId = _userId }; + } [Fact] public async Task Handle_WithCustomItems_ShouldRegisterBudgetAndReturnDetailResponse() @@ -57,7 +66,8 @@ public async Task Handle_WithCustomItems_ShouldRegisterBudgetAndReturnDetailResp result.Value.Items[0].MaterialName.ShouldBe("Cabo 10mm"); result.Value.Items[0].TotalPrice.ShouldBe(31.00m); - _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _budgetWriteRepoMock.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); } @@ -76,8 +86,11 @@ public async Task Handle_WithMaterialId_ShouldValidateMaterialAndRegisterBudget( .ReturnsAsync(material); var command = new RegisterBudgetCommand( - _clientId, - [new RegisterBudgetItemCommand(materialId, "Cabo 10mm", MaterialUnit.Metro, 3, 10.00m)]) + _clientId, + [ + new RegisterBudgetItemCommand(materialId, "Cabo 10mm", BudgetItemType.Material, + MaterialUnit.Metro, 3, 10.00m) + ]) { UserId = _userId }; var handler = CreateHandler(); @@ -85,14 +98,17 @@ [new RegisterBudgetItemCommand(materialId, "Cabo 10mm", MaterialUnit.Metro, 3, 1 result.IsError.ShouldBeFalse(); result.Value.Items[0].MaterialId.ShouldBe(materialId); - _materialReadRepoMock.Verify(r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny()), Times.Once); + _materialReadRepoMock.Verify( + r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny()), + Times.Once); } [Fact] public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() { _clientReadRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => + r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) .ReturnsAsync((Client?)null); var handler = CreateHandler(); @@ -101,7 +117,8 @@ public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() result.IsError.ShouldBeTrue(); result.FirstError.Type.ShouldBe(ErrorType.NotFound); result.FirstError.Description.ShouldBe(ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); - _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _budgetWriteRepoMock.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -112,12 +129,16 @@ public async Task Handle_WhenMaterialIdNotFound_ShouldReturnNotFoundError() .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) .ReturnsAsync(client); _materialReadRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => + r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) .ReturnsAsync((Material?)null); var command = new RegisterBudgetCommand( - _clientId, - [new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", MaterialUnit.Metro, 1, 10.00m)]) + _clientId, + [ + new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", + BudgetItemType.Material, MaterialUnit.Metro, 1, 10.00m) + ]) { UserId = _userId }; var handler = CreateHandler(); @@ -126,6 +147,7 @@ [new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", MaterialUnit.Metro, result.IsError.ShouldBeTrue(); result.FirstError.Type.ShouldBe(ErrorType.NotFound); result.FirstError.Description.ShouldBe(ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); - _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _budgetWriteRepoMock.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs index eb399ab..7ef9d1f 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs @@ -9,8 +9,13 @@ public class RegisterBudgetCommandValidatorTests { private readonly RegisterBudgetCommandValidator _validator = new(); - private static RegisterBudgetCommand ValidCommand() => - new(Guid.NewGuid(), [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)]); + private static RegisterBudgetCommand ValidCommand() + { + return new RegisterBudgetCommand(Guid.NewGuid(), [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, + null, 2, 15.50m) + ]); + } [Fact] public void Validate_WithValidData_ShouldHaveNoErrors() @@ -44,7 +49,11 @@ public void Validate_WithEmptyItemMaterialName_ShouldHaveError(string? name) { var command = ValidCommand() with { - Items = [new RegisterBudgetItemCommand(null, name!, MaterialUnit.Metro, 1, 10.00m)] + Items = + [ + new RegisterBudgetItemCommand(null, name!, BudgetItemType.MaoDeObra, null, 1, + 10.00m) + ] }; _validator.TestValidate(command) .ShouldHaveValidationErrorFor("Items[0].MaterialName") @@ -58,7 +67,11 @@ public void Validate_WithInvalidItemQuantity_ShouldHaveError(int quantity) { var command = ValidCommand() with { - Items = [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, quantity, 15.50m)] + Items = + [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, + quantity, 15.50m) + ] }; _validator.TestValidate(command) .ShouldHaveValidationErrorFor("Items[0].Quantity") @@ -72,7 +85,11 @@ public void Validate_WithInvalidItemUnitPrice_ShouldHaveError(double price) { var command = ValidCommand() with { - Items = [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 1, (decimal)price)] + Items = + [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, 1, + (decimal)price) + ] }; _validator.TestValidate(command) .ShouldHaveValidationErrorFor("Items[0].UnitPrice") diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..f4f1bf7 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs @@ -0,0 +1,78 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.RejectBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class RejectBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private RejectBudgetCommandHandler CreateHandler() + { + return new RejectBudgetCommandHandler( + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithFinalizedBudget_ShouldRejectAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(item); + budget.FinalizeBudget(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new RejectBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.Status.ShouldBe(BudgetStatus.Rejected); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new RejectBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..ae72b8a --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs @@ -0,0 +1,171 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Domain.ValueObjects; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class UpdateBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _clientReadRepoMock = new(); + private readonly Mock _materialReadRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private UpdateBudgetCommandHandler CreateHandler() + { + return new UpdateBudgetCommandHandler( + _clientReadRepoMock.Object, + _materialReadRepoMock.Object, + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + private Client MakeClient() + { + return Client.Register(_userId, "João Silva", "(11) 99999-9999", + Email.Create("joao@example.com").Value, + Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); + } + + [Fact] + public async Task Handle_WithValidData_ShouldUpdateBudgetAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var oldItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(oldItem); + + var client = MakeClient(); + var newClientId = Guid.NewGuid(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(newClientId, _userId, It.IsAny())) + .ReturnsAsync(client); + + var command = new UpdateBudgetCommand( + _budgetId, + newClientId, + [ + new UpdateBudgetItemCommand(null, "Disjuntor", BudgetItemType.MaoDeObra, null, 1, 50m) + ]) + { UserId = _userId }; + + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.ClientId.ShouldBe(newClientId); + budget.TotalAmount.ShouldBe(50m); + budget.Items.Count.ShouldBe(1); + budget.Items.First().MaterialName.ShouldBe("Disjuntor"); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new UpdateBudgetCommand(_budgetId, _clientId, []) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + } + + [Fact] + public async Task Handle_WhenClientNotFound_ShouldReturnNotFound() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync((Client?)null); + + var command = new UpdateBudgetCommand(_budgetId, _clientId, []) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); + } + + [Fact] + public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFound() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var client = MakeClient(); + var materialId = Guid.NewGuid(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync(client); + + _materialReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny())) + .ReturnsAsync((Material?)null); + + var command = new UpdateBudgetCommand( + _budgetId, + _clientId, + [ + new UpdateBudgetItemCommand(materialId, "Cabo 10mm", BudgetItemType.Material, MaterialUnit.Metro, 1, 10m) + ]) + { UserId = _userId }; + + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs new file mode 100644 index 0000000..84bed4d --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs @@ -0,0 +1,98 @@ +using FluentValidation.TestHelper; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class UpdateBudgetCommandValidatorTests +{ + private readonly UpdateBudgetCommandValidator _validator = new(); + + private static UpdateBudgetCommand ValidCommand() + { + return new UpdateBudgetCommand(Guid.NewGuid(), Guid.NewGuid(), [ + new UpdateBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, + null, 2, 15.50m) + ]); + } + + [Fact] + public void Validate_WithValidData_ShouldHaveNoErrors() + { + _validator.TestValidate(ValidCommand()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_WithEmptyClientId_ShouldHaveError() + { + var command = ValidCommand() with { ClientId = Guid.Empty }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.ClientId) + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + } + + [Fact] + public void Validate_WithEmptyItems_ShouldHaveError() + { + var command = ValidCommand() with { Items = [] }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Items) + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Validate_WithEmptyItemMaterialName_ShouldHaveError(string? name) + { + var command = ValidCommand() with + { + Items = + [ + new UpdateBudgetItemCommand(null, name!, BudgetItemType.MaoDeObra, null, 1, + 10.00m) + ] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].MaterialName") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Validate_WithInvalidItemQuantity_ShouldHaveError(int quantity) + { + var command = ValidCommand() with + { + Items = + [ + new UpdateBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, + quantity, 15.50m) + ] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].Quantity") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + } + + [Theory] + [InlineData(0)] + [InlineData(-0.01)] + public void Validate_WithInvalidItemUnitPrice_ShouldHaveError(double price) + { + var command = ValidCommand() with + { + Items = + [ + new UpdateBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, 1, + (decimal)price) + ] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].UnitPrice") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs index 7abfa31..cdff9a8 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs @@ -1,7 +1,6 @@ using ErrorOr; using Moq; using Shouldly; -using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Queries.GetBudgetById; using Voltiq.Domain.Entities; using Voltiq.Domain.Enums; @@ -13,13 +12,15 @@ namespace Voltiq.Application.Tests.Features.Budgets.Queries; public class GetBudgetByIdQueryHandlerTests { + private readonly Guid _budgetId = Guid.NewGuid(); private readonly Mock _budgetReadRepoMock = new(); private readonly Guid _userId = Guid.NewGuid(); - private readonly Guid _budgetId = Guid.NewGuid(); - private GetBudgetByIdQueryHandler CreateHandler() => - new(_budgetReadRepoMock.Object); + private GetBudgetByIdQueryHandler CreateHandler() + { + return new GetBudgetByIdQueryHandler(_budgetReadRepoMock.Object); + } private static Budget MakeBudgetWithItemsAndClient(Guid userId, Guid budgetId) { @@ -29,7 +30,8 @@ private static Budget MakeBudgetWithItemsAndClient(Guid userId, Guid budgetId) var budget = Budget.Register(userId, client.Id); typeof(Budget).GetProperty("Id")!.SetValue(budget, budgetId); - budget.AddItem(BudgetItem.Create(budgetId, null, "Fio elétrico", MaterialUnit.Metro, 5, 8.00m)); + budget.AddItem(BudgetItem.Create(budgetId, null, BudgetItemType.MaoDeObra, null, 5, 8.00m, + "Fio elétrico")); typeof(Budget).GetProperty("Client")!.SetValue(budget, client); return budget; @@ -40,7 +42,8 @@ public async Task Handle_WhenBudgetExists_ShouldReturnDetailResponse() { var budget = MakeBudgetWithItemsAndClient(_userId, _budgetId); _budgetReadRepoMock - .Setup(r => r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) + .Setup(r => + r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync(budget); var handler = CreateHandler(); @@ -64,7 +67,8 @@ public async Task Handle_WhenBudgetExists_ShouldReturnDetailResponse() public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFoundError() { _budgetReadRepoMock - .Setup(r => r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) + .Setup(r => + r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync((Budget?)null); var handler = CreateHandler(); diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs index 9e29e88..663555a 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs @@ -26,7 +26,7 @@ private static Budget MakeBudgetWithClient(Guid userId) Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); var budget = Budget.Register(userId, client.Id); - budget.AddItem(BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)); + budget.AddItem(BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm")); typeof(Budget).GetProperty("Client")!.SetValue(budget, client); diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs new file mode 100644 index 0000000..7c792ab --- /dev/null +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs @@ -0,0 +1,127 @@ +using Shouldly; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Exceptions; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Domain.Tests.Entities; + +public class BudgetItemTests +{ + private static readonly Guid ValidBudgetId = Guid.NewGuid(); + private static readonly Guid ValidMaterialId = Guid.NewGuid(); + private const string VALID_NAME = "Cabo 10mm"; + + private static BudgetItem ValidMaterialItemCreate() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, VALID_NAME); + + [Fact] + public void Create_MaterialTypeWithValidData_ShouldSucceed() + { + var item = ValidMaterialItemCreate(); + + item.Type.ShouldBe(BudgetItemType.Material); + item.MaterialId.ShouldNotBe(null); + item.MaterialId.ShouldBe(ValidMaterialId); + item.Unit.ShouldBe(MaterialUnit.Metro); + } + + [Fact] + public void Create_MaoDeObraTypeWithValidData_ShouldSucceed() + { + var item = BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 8, 120m, "Mão de obra elétrica"); + + item.Type.ShouldBe(BudgetItemType.MaoDeObra); + item.MaterialId.ShouldBeNull(); + item.Unit.ShouldBeNull(); + } + + [Fact] + public void Create_OutrosTypeWithValidData_ShouldSucceed() + { + var item = BudgetItem.Create(ValidBudgetId, null, BudgetItemType.Outros, null, 1, 50m, "Deslocamento"); + + item.Type.ShouldBe(BudgetItemType.Outros); + item.MaterialId.ShouldBeNull(); + item.Unit.ShouldBeNull(); + } + + [Fact] + public void Create_MaterialTypeWithoutMaterialId_ShouldThrowDomainException() + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, VALID_NAME)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + } + + [Fact] + public void Create_MaterialTypeWithoutUnit_ShouldThrowDomainException() + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, null, 2, 15.50m, VALID_NAME)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + } + + [Fact] + public void Create_MaoDeObraTypeWithMaterialId_ShouldThrowDomainException() + { + var ex = Should.Throw(() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.MaoDeObra, null, 8, 120m, "Mão de obra")); + + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void Create_MaoDeObraTypeWithUnit_ShouldThrowDomainException() + { + var ex = Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, MaterialUnit.Metro, 8, 120m, "Mão de obra")); + + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void Create_OutrosTypeWithMaterialId_ShouldThrowDomainException() + { + var ex = Should.Throw(() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Outros, null, 1, 50m, "Deslocamento")); + + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void Create_WithEmptyName_ShouldThrowDomainException() + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 2, 10m, "")) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithInvalidQuantity_ShouldThrowDomainException(int quantity) + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, quantity, 10m, VALID_NAME)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithInvalidUnitPrice_ShouldThrowDomainException(decimal unitPrice) + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 3, unitPrice, VALID_NAME)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + } + + [Fact] + public void Create_MaoDeObraType_ShouldCalculateTotalPriceCorrectly() + { + var item = BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 3, 10m, "Instalação"); + + item.TotalPrice.ShouldBe(30m); + } +} diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs index bd3dd0f..bfa76d1 100644 --- a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs @@ -52,7 +52,7 @@ public void Register_WithEmptyUserId_ShouldThrowDomainException() public void AddItem_ShouldRecalculateTotalAmount() { var budget = Budget.Register(ValidUserId, ValidClientId); - var item = BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); budget.AddItem(item); @@ -64,12 +64,150 @@ public void AddItem_ShouldRecalculateTotalAmount() public void AddMultipleItems_ShouldSumAllTotals() { var budget = Budget.Register(ValidUserId, ValidClientId); - var item1 = BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 10m); - var item2 = BudgetItem.Create(budget.Id, null, "Disjuntor", MaterialUnit.Unidade, 1, 50m); + var item1 = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo 10mm"); + var item2 = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); budget.AddItem(item1); budget.AddItem(item2); budget.TotalAmount.ShouldBe(70m); } + + [Fact] + public void Edit_WithValidData_ShouldUpdateBudgetAndRecalculateTotals() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var oldItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo 10mm"); + budget.AddItem(oldItem); + + var newClientId = Guid.NewGuid(); + var newItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); + + budget.Edit(newClientId, new[] { newItem }); + + budget.ClientId.ShouldBe(newClientId); + budget.TotalAmount.ShouldBe(50m); + budget.Items.Count.ShouldBe(1); + budget.Items.ShouldContain(newItem); + budget.Items.ShouldNotContain(oldItem); + } + + [Fact] + public void Edit_WhenNotDraft_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var newItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); + + var statusProp = typeof(Budget).GetProperty(nameof(Budget.Status)); + statusProp!.SetValue(budget, BudgetStatus.Approved); + + Should.Throw(() => + budget.Edit(ValidClientId, new[] { newItem })) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO); + } + + [Fact] + public void Edit_WithEmptyClientId_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var newItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); + + Should.Throw(() => + budget.Edit(Guid.Empty, new[] { newItem })) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + } + + [Fact] + public void Edit_WithEmptyItems_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.Edit(ValidClientId, Array.Empty())) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Fact] + public void FinalizeBudget_WithValidDraftBudget_ShouldTransitionToFinalized() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + + budget.FinalizeBudget(); + + budget.Status.ShouldBe(BudgetStatus.Finalized); + budget.DomainEvents.ShouldContain(e => e is BudgetFinalizedEvent); + } + + [Fact] + public void FinalizeBudget_WhenAlreadyFinalized_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + Should.Throw(() => + budget.FinalizeBudget()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO); + } + + [Fact] + public void FinalizeBudget_WithEmptyItems_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.FinalizeBudget()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Fact] + public void Approve_WithFinalizedBudget_ShouldTransitionToApproved() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + budget.Approve(); + + budget.Status.ShouldBe(BudgetStatus.Approved); + budget.DomainEvents.ShouldContain(e => e is BudgetApprovedEvent); + } + + [Fact] + public void Approve_WhenDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.Approve()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO); + } + + [Fact] + public void Reject_WithFinalizedBudget_ShouldTransitionToRejected() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + budget.Reject(); + + budget.Status.ShouldBe(BudgetStatus.Rejected); + budget.DomainEvents.ShouldContain(e => e is BudgetRejectedEvent); + } + + [Fact] + public void Reject_WhenDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.Reject()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO); + } } diff --git a/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs b/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs index a1f95a3..963b64e 100644 --- a/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs +++ b/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs @@ -132,7 +132,7 @@ public async Task GetByIdWithItemsAndUserIdAsync_ShouldReturnBudgetWithItems() var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); var budget = TestDataBuilder.MakeBudget(user.Id, client.Id); - var item = BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); budget.AddItem(item); await _budgetRepository.AddAsync(budget, TestContext.Current.CancellationToken); await _unitOfWork.SaveChangesAsync(TestContext.Current.CancellationToken);