From 10db654679c26bcb96129a2c5e481da712b30898 Mon Sep 17 00:00:00 2001 From: leandro Date: Thu, 11 Jun 2026 20:00:57 -0300 Subject: [PATCH] feat(sales): add sales document draft review and validation close #74 --- Core/Interfaces/ISalesDocumentDom.cs | 3 + Core/Services/SalesDocumentService.cs | 168 +++++++ .../Sales/SalesDocumentDraftPreviewDto.cs | 9 + .../Dtos/Sales/SalesDocumentDraftReviewDto.cs | 13 + .../Sales/SalesDocumentDraftValidationDto.cs | 12 + .../Interfaces/IPhSSalesDocumentRepository.cs | 2 + .../PhSSalesDocumentRepository.cs | 46 ++ ...-sales-document-draft-review-validation.md | 165 +++++++ .../Sales/SalesDocumentController.cs | 73 +++ .../SalesDocuments/SalesDocumentDetail.razor | 15 +- .../SalesDocuments/SalesDocumentReview.razor | 444 ++++++++++++++++++ .../SalesDocumentReview.razor.css | 100 ++++ .../Sales/SalesDocuments/SalesDocuments.razor | 5 + .../SalesDocuments/ISalesDocumentService.cs | 3 + .../SalesDocuments/SalesDocumentService.cs | 39 ++ 15 files changed, 1094 insertions(+), 3 deletions(-) create mode 100644 Domain/Dtos/Sales/SalesDocumentDraftPreviewDto.cs create mode 100644 Domain/Dtos/Sales/SalesDocumentDraftReviewDto.cs create mode 100644 Domain/Dtos/Sales/SalesDocumentDraftValidationDto.cs create mode 100644 docs/stories/story-74-sales-document-draft-review-validation.md create mode 100644 phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor create mode 100644 phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor.css diff --git a/Core/Interfaces/ISalesDocumentDom.cs b/Core/Interfaces/ISalesDocumentDom.cs index 57d9448..bba1178 100644 --- a/Core/Interfaces/ISalesDocumentDom.cs +++ b/Core/Interfaces/ISalesDocumentDom.cs @@ -18,6 +18,9 @@ namespace Core.Interfaces Task CreateAsync(SalesDocumentCreateRequest request); Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request); + Task GetDraftPreviewAsync(int id); + Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review); + Task ValidateDraftAsync(int id); Task> SearchDeliveryNoteCandidatesAsync( int? customerId, string? customerText, diff --git a/Core/Services/SalesDocumentService.cs b/Core/Services/SalesDocumentService.cs index 958a180..f98ee58 100644 --- a/Core/Services/SalesDocumentService.cs +++ b/Core/Services/SalesDocumentService.cs @@ -317,6 +317,174 @@ namespace Core.Services return _salesDocumentRepository.GetDtoByIdAsync(id); } + + public async Task GetDraftPreviewAsync(int id) + { + if (id <= 0) + throw new ArgumentOutOfRangeException(nameof(id)); + + var document = await _salesDocumentRepository.GetDtoByIdAsync(id); + return document == null ? null : BuildDraftPreview(document); + } + + public async Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review) + { + if (id <= 0) + throw new ArgumentOutOfRangeException(nameof(id)); + + ArgumentNullException.ThrowIfNull(review); + + var current = await _salesDocumentRepository.GetDtoByIdAsync(id); + if (current == null) + return null; + + if (current.Status != (int)SalesDocumentStatus.Draft) + throw new InvalidOperationException("Solo se pueden revisar Sales Documents en estado Draft."); + + var updated = await _salesDocumentRepository.UpdateDraftReviewAsync(id, review); + return updated == null ? null : BuildDraftPreview(updated); + } + + public async Task ValidateDraftAsync(int id) + { + if (id <= 0) + throw new ArgumentOutOfRangeException(nameof(id)); + + var current = await _salesDocumentRepository.GetDtoByIdAsync(id); + if (current == null) + return null; + + var validation = BuildDraftValidation(current); + if (!validation.IsValid) + throw new InvalidOperationException(string.Join(" ", validation.Errors)); + + var validated = await _salesDocumentRepository.ValidateDraftAsync(id); + return validated == null ? null : BuildDraftPreview(validated); + } + + private static SalesDocumentDraftPreviewDto BuildDraftPreview(SalesDocumentDto document) + { + return new SalesDocumentDraftPreviewDto + { + Document = document, + OriginDeliveryNotes = BuildOriginDeliveryNotes(document), + Validation = BuildDraftValidation(document) + }; + } + + private static SalesDocumentDraftValidationDto BuildDraftValidation(SalesDocumentDto document) + { + var validation = new SalesDocumentDraftValidationDto + { + HasDetails = document.Details.Any(), + HasValidAmounts = document.TotalAmount > 0 && document.NetAmount >= 0 && document.TaxAmount >= 0, + HasCustomer = document.CustomerId > 0, + IsDraft = document.Status == (int)SalesDocumentStatus.Draft + }; + + if (!validation.HasDetails) + validation.Errors.Add("El documento debe tener detalles."); + + if (!validation.HasValidAmounts) + validation.Errors.Add("El documento debe tener importes validos."); + + if (!validation.HasCustomer) + validation.Errors.Add("El documento debe tener cliente asignado."); + + if (!validation.IsDraft) + validation.Errors.Add("El documento debe permanecer en estado Draft."); + + return validation; + } + + private static List BuildOriginDeliveryNotes(SalesDocumentDto document) + { + return document.Details + .Where(x => string.Equals(x.OriginType, SalesDocumentOriginType.DeliveryNote.ToStorageCode(), StringComparison.OrdinalIgnoreCase)) + .Select(TryBuildDeliveryNoteSummary) + .Where(x => x is not null) + .Select(x => x!) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .OrderBy(x => x.IssueDate) + .ThenBy(x => x.Id) + .ToList(); + } + + private static DeliveryNoteSummaryDto? TryBuildDeliveryNoteSummary(SalesDocumentDetailDto detail) + { + if (string.IsNullOrWhiteSpace(detail.OriginSnapshotJson)) + { + return detail.OriginId.HasValue + ? new DeliveryNoteSummaryDto + { + Id = detail.OriginId.Value, + DeliveryNoteNumber = $"Remito #{detail.OriginId.Value}" + } + : null; + } + + try + { + using var jsonDocument = JsonDocument.Parse(detail.OriginSnapshotJson); + var root = jsonDocument.RootElement; + + var deliveryNoteId = root.TryGetProperty("deliveryNoteId", out var idProperty) && idProperty.TryGetInt32(out var parsedId) + ? parsedId + : detail.OriginId; + + if (!deliveryNoteId.HasValue) + return null; + + var issueDate = DateTime.MinValue; + if (root.TryGetProperty("deliveryNoteIssueDate", out var dateProperty) && + dateProperty.ValueKind == JsonValueKind.String && + DateTime.TryParse(dateProperty.GetString(), out var parsedDate)) + { + issueDate = parsedDate; + } + + return new DeliveryNoteSummaryDto + { + Id = deliveryNoteId.Value, + DeliveryNoteNumber = ReadString(root, "deliveryNoteNumber") ?? $"Remito #{deliveryNoteId.Value}", + QuoteId = ReadInt(root, "quoteId"), + QuoteNumber = ReadString(root, "quoteNumber"), + IssueDate = issueDate, + CustomerId = ReadInt(root, "customerId") ?? 0, + CustomerName = ReadString(root, "customerName") ?? string.Empty, + Status = "Emitido" + }; + } + catch + { + return detail.OriginId.HasValue + ? new DeliveryNoteSummaryDto + { + Id = detail.OriginId.Value, + DeliveryNoteNumber = $"Remito #{detail.OriginId.Value}" + } + : null; + } + } + + private static string? ReadString(JsonElement root, string propertyName) + { + return root.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static int? ReadInt(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var property)) + return null; + + if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value)) + return value; + + return null; + } private static void ValidateDetail(SalesDocumentCreateDetailRequest detail) { if (detail.LineNumber <= 0) diff --git a/Domain/Dtos/Sales/SalesDocumentDraftPreviewDto.cs b/Domain/Dtos/Sales/SalesDocumentDraftPreviewDto.cs new file mode 100644 index 0000000..843e2b0 --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDraftPreviewDto.cs @@ -0,0 +1,9 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDraftPreviewDto + { + public SalesDocumentDto Document { get; set; } = new(); + public List OriginDeliveryNotes { get; set; } = new(); + public SalesDocumentDraftValidationDto Validation { get; set; } = new(); + } +} diff --git a/Domain/Dtos/Sales/SalesDocumentDraftReviewDto.cs b/Domain/Dtos/Sales/SalesDocumentDraftReviewDto.cs new file mode 100644 index 0000000..0afe432 --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDraftReviewDto.cs @@ -0,0 +1,13 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDraftReviewDto + { + public DateTime? IssueDate { get; set; } + public string? AssociatedDocumentType { get; set; } + public string? AssociatedDocumentNumber { get; set; } + public DateTime? AssociatedDocumentDate { get; set; } + public string? Observations { get; set; } + public DateTime? PeriodFrom { get; set; } + public DateTime? PeriodTo { get; set; } + } +} diff --git a/Domain/Dtos/Sales/SalesDocumentDraftValidationDto.cs b/Domain/Dtos/Sales/SalesDocumentDraftValidationDto.cs new file mode 100644 index 0000000..addde77 --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDraftValidationDto.cs @@ -0,0 +1,12 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDraftValidationDto + { + public bool HasDetails { get; set; } + public bool HasValidAmounts { get; set; } + public bool HasCustomer { get; set; } + public bool IsDraft { get; set; } + public bool IsValid => HasDetails && HasValidAmounts && HasCustomer && IsDraft; + public List Errors { get; set; } = new(); + } +} diff --git a/Models/Interfaces/IPhSSalesDocumentRepository.cs b/Models/Interfaces/IPhSSalesDocumentRepository.cs index f1d2b7e..fad228f 100644 --- a/Models/Interfaces/IPhSSalesDocumentRepository.cs +++ b/Models/Interfaces/IPhSSalesDocumentRepository.cs @@ -30,5 +30,7 @@ namespace Models.Interfaces int page = 1, int pageSize = 50); Task GetDtoByIdAsync(int id); + Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review); + Task ValidateDraftAsync(int id); } } diff --git a/Models/Repositories/PhSSalesDocumentRepository.cs b/Models/Repositories/PhSSalesDocumentRepository.cs index d963251..c32de4a 100644 --- a/Models/Repositories/PhSSalesDocumentRepository.cs +++ b/Models/Repositories/PhSSalesDocumentRepository.cs @@ -313,6 +313,9 @@ namespace Models.Repositories NetAmount = entity.NetAmount, TaxAmount = entity.TaxAmount, TotalAmount = entity.TotalAmount, + AssociatedDocumentType = entity.AssociatedDocumentType, + AssociatedDocumentNumber = entity.AssociatedDocumentNumber, + AssociatedDocumentDate = entity.AssociatedDocumentDate, Observations = entity.Observations, ExtraInfoJson = entity.ExtraInfoJson, PeriodFrom = entity.PeriodFrom, @@ -359,5 +362,48 @@ namespace Models.Repositories }).ToList() }; } + + public async Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review) + { + var entity = await _context.PhSSalesDocuments + .FirstOrDefaultAsync(x => x.Id == id); + + if (entity == null) + return null; + + if (entity.Status != (int)Domain.Constants.SalesDocumentStatus.Draft) + throw new InvalidOperationException("Solo se pueden revisar Sales Documents en estado Draft."); + + entity.IssueDate = review.IssueDate; + entity.AssociatedDocumentType = string.IsNullOrWhiteSpace(review.AssociatedDocumentType) ? null : review.AssociatedDocumentType.Trim(); + entity.AssociatedDocumentNumber = string.IsNullOrWhiteSpace(review.AssociatedDocumentNumber) ? null : review.AssociatedDocumentNumber.Trim(); + entity.AssociatedDocumentDate = review.AssociatedDocumentDate; + entity.Observations = string.IsNullOrWhiteSpace(review.Observations) ? null : review.Observations.Trim(); + entity.PeriodFrom = review.PeriodFrom; + entity.PeriodTo = review.PeriodTo; + entity.Modifiedat = DateTime.Now; + + await _context.SaveChangesAsync(); + return await GetDtoByIdAsync(id); + } + + public async Task ValidateDraftAsync(int id) + { + var entity = await _context.PhSSalesDocuments + .Include(x => x.PhSSalesDocumentDetails) + .FirstOrDefaultAsync(x => x.Id == id); + + if (entity == null) + return null; + + if (entity.Status != (int)Domain.Constants.SalesDocumentStatus.Draft) + throw new InvalidOperationException("Solo se pueden validar Sales Documents en estado Draft."); + + entity.Status = (int)Domain.Constants.SalesDocumentStatus.Validated; + entity.Modifiedat = DateTime.Now; + + await _context.SaveChangesAsync(); + return await GetDtoByIdAsync(id); + } } } diff --git a/docs/stories/story-74-sales-document-draft-review-validation.md b/docs/stories/story-74-sales-document-draft-review-validation.md new file mode 100644 index 0000000..c11b642 --- /dev/null +++ b/docs/stories/story-74-sales-document-draft-review-validation.md @@ -0,0 +1,165 @@ +# PhronCare — Story #74: Sales Document Draft Review & Validation + +## Objetivo + +Incorporar una etapa formal de revisión administrativa para Sales Documents en estado Draft antes de su futura utilización en procesos de facturación fiscal. + +La story debe permitir revisar, completar, validar y aprobar un documento comercial sin realizar ninguna emisión fiscal. + +--- + +## Contexto funcional + +Actualmente: + +Delivery Note + ↓ +Sales Document + +El documento se crea correctamente y queda almacenado con estado Draft. + +Sin embargo: + +- no existe una pantalla de revisión; +- no existe un proceso formal de validación; +- no existe una separación clara entre creación y aprobación; +- el usuario no puede revisar fácilmente la información antes de continuar el circuito comercial. + +Antes de implementar integración ARCA se requiere formalizar esta etapa. + +--- + +## Alcance + +### Domain + +Incorporar DTOs específicos para revisión y validación: + +- SalesDocumentDraftPreviewDto +- SalesDocumentDraftReviewDto +- SalesDocumentDraftValidationDto + +### Core + +Extender SalesDocumentService para: + +- Obtener preview completo. +- Actualizar información editable del draft. +- Validar draft. +- Cambiar estado Draft → Validated. + +### Data + +- Extender repositorios existentes. +- No crear nuevas tablas. +- No modificar modelos scaffold. +- Reutilizar PhS_SalesDocuments como fuente de verdad. + +### API + +Agregar endpoints: + +GET /api/SalesDocument/{id}/draft-preview +PUT /api/SalesDocument/{id}/draft-review +POST /api/SalesDocument/{id}/validate + +### UI BackOffice + +Nueva pantalla: + +SalesDocumentReview.razor + +Ruta: + +/salesdocuments/{id}/review + +Debe mostrar: + +- Cabecera del documento +- Estado +- Remitos origen +- Cliente +- Bill To Customer +- Coverage +- Items +- Importes +- Observaciones + +Permitir: + +- Guardar revisión +- Validar documento + +--- + +## Reglas de negocio + +Un documento podrá validarse únicamente si: + +- Tiene detalles. +- Tiene importes válidos. +- Posee cliente asignado. +- Permanece en estado Draft. + +Una vez validado: + +Draft → Validated + +No podrá modificarse mediante la pantalla de revisión. + +--- + +## Fuera de alcance + +- ARCA +- AFIP +- CAE +- WSFE +- Numeración fiscal +- Factura A/B/C +- Cálculo IVA +- Condición fiscal +- Notas de crédito +- Notas de débito +- PDF fiscal + +--- + +## Criterios de aceptación + +- Se puede visualizar un Draft completo. +- Se puede revisar un Draft. +- Se puede guardar la revisión. +- Se puede validar un Draft. +- El estado cambia a Validated. +- No se modifican modelos EF generados. +- Se mantienen contratos existentes. +- Compila correctamente. + +--- + +## Decisiones de diseño + +- Sales Document continúa siendo la entidad principal. +- No se crea entidad SalesDocumentDraft. +- El concepto Draft se representa mediante Status. +- La revisión administrativa se implementa como workflow sobre el documento existente. +- La futura emisión fiscal se implementará en una story posterior. + +--- + +## Entregable esperado + +- Domain/* +- Core/* +- Models/* +- API/* +- UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor + +### Branch sugerido + +feature/leandro/74-sales-document-draft-review + +### Commit sugerido + +feat(sales): add sales document draft review and validation close #74 diff --git a/phronCare.API/Controllers/Sales/SalesDocumentController.cs b/phronCare.API/Controllers/Sales/SalesDocumentController.cs index dca3cfa..9bc2f61 100644 --- a/phronCare.API/Controllers/Sales/SalesDocumentController.cs +++ b/phronCare.API/Controllers/Sales/SalesDocumentController.cs @@ -69,6 +69,79 @@ namespace phronCare.API.Controllers.Sales } } + [HttpGet("{id:int}/draft-preview")] + public async Task> GetDraftPreview(int id) + { + try + { + var preview = await _salesDocumentService.GetDraftPreviewAsync(id); + if (preview == null) + return NotFound($"Sales Document con ID {id} no encontrado."); + + return Ok(preview); + } + catch (Exception ex) + { + var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod"; + return StatusCode(500, $"{methodName} Message: {ex.Message}"); + } + } + + [HttpPut("{id:int}/draft-review")] + public async Task> UpdateDraftReview(int id, [FromBody] SalesDocumentDraftReviewDto request) + { + try + { + if (request == null) + return BadRequest("El payload no puede ser nulo."); + + var preview = await _salesDocumentService.UpdateDraftReviewAsync(id, request); + if (preview == null) + return NotFound($"Sales Document con ID {id} no encontrado."); + + return Ok(preview); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod"; + return StatusCode(500, $"{methodName} Message: {ex.Message}"); + } + } + + [HttpPost("{id:int}/validate")] + public async Task> ValidateDraft(int id) + { + try + { + var preview = await _salesDocumentService.ValidateDraftAsync(id); + if (preview == null) + return NotFound($"Sales Document con ID {id} no encontrado."); + + return Ok(preview); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod"; + return StatusCode(500, $"{methodName} Message: {ex.Message}"); + } + } + [HttpGet("delivery-note-candidates")] public async Task>> SearchDeliveryNoteCandidates( diff --git a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentDetail.razor b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentDetail.razor index 1ff007d..e4744d0 100644 --- a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentDetail.razor +++ b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentDetail.razor @@ -9,9 +9,17 @@

Sales Document

- +
+ @if (Document?.Status == (int)SalesDocumentStatus.Draft) + { + + } + +
@if (IsLoading) @@ -198,6 +206,7 @@ } private void BackToList() => Navigation.NavigateTo("/salesdocuments"); + private void ReviewDraft() => Navigation.NavigateTo($"/salesdocuments/{Id}/review"); private string GetOriginSummary() { diff --git a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor new file mode 100644 index 0000000..9d700e5 --- /dev/null +++ b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor @@ -0,0 +1,444 @@ +@page "/salesdocuments/{Id:int}/review" +@using Domain.Constants +@using Domain.Dtos.Sales +@using phronCare.UIBlazor.Services.Sales.SalesDocuments +@inject NavigationManager Navigation +@inject ISalesDocumentService SalesDocumentService +@inject IToastService toastService + +
+
+
+

Revision administrativa

+
Sales Document Draft Review & Validation
+
+
+ + +
+
+ + @if (IsLoading) + { +
+
Cargando...
+
+ } + else if (Document is null) + { +
No se pudo cargar el Sales Document.
+ } + else + { +
+ @if (IsDraft) + { + Este documento esta en Draft y puede revisarse visualmente. Guardar y validar requieren los endpoints de backend fuera del alcance UI-only. + } + else + { + Este documento no esta en Draft. La revision queda en modo solo lectura. + } +
+ +
+
+
+
+
Cabecera del documento
+ @GetStatusLabel(Document.Status) +
+
+
+
+ +
@DocumentNumber
+
+
+ +
@GetDocumentTypeLabel(Document.DocumentType)
+
+
+ +
@FormatDate(Document.IssueDate)
+
+
+ +
@Document.Currency
+
+
+ +
@Document.CustomerName
+
+
+ +
@Document.BillToCustomerName
+
+
+ +
@(Document.QuoteId?.ToString() ?? "-")
+
+
+ +
@FormatDate(Document.PeriodFrom)
+
+
+ +
@FormatDate(Document.PeriodTo)
+
+
+ +
@Document.ExchangeRate.ToString("N4")
+
+
+ + +
+
+
+
+ +
+
+
Remitos origen
+
+
+ @if (OriginDeliveryNotes.Any()) + { +
+ @foreach (var item in OriginDeliveryNotes) + { +
+
+ @item.DeliveryNoteNumber + Remito ID @item.Id + @FormatDate(item.IssueDate) +
+
+ } +
+ } + else + { +
No se detectaron remitos origen en el snapshot del documento.
+ } +
+
+ +
+
+
Items
+
+
+ + + + + + + + + + + + + + + @if (Document.Details.Any()) + { + @foreach (var item in Document.Details.OrderBy(x => x.LineNumber)) + { + + + + + + + + + + + } + } + else + { + + } + +
#OrigenDescripcionCantidadUnitarioNetoImpuestoTotal
@item.LineNumber@GetOriginTypeLabel(item.OriginType)@item.Description@item.Quantity.ToString("N2")@item.UnitPrice.ToString("N2")@item.NetAmount.ToString("N2")@item.TaxAmount.ToString("N2")@item.TotalAmount.ToString("N2")
Sin items.
+
+
+ +
+
+
Coverage
+
+
+ + + + + + + + + + + + + + + @if (Document.Coverage.Any()) + { + @foreach (var coverage in Document.Coverage) + { + + + + + + + + + + + } + } + else + { + + } + +
TipoPresupuestoQuote DetailPorcentajeImporteDesdeHastaNotas
@GetCoverageTypeLabel(coverage.CoverageType)@coverage.QuoteId@(coverage.QuoteDetailId?.ToString() ?? "-")@(coverage.CoveragePercentage?.ToString("N2") ?? "-")@(coverage.CoverageAmount?.ToString("N2") ?? "-")@FormatDate(coverage.PeriodFrom)@FormatDate(coverage.PeriodTo)@(string.IsNullOrWhiteSpace(coverage.Notes) ? "-" : coverage.Notes)
Sin coverage informado.
+
+
+
+ +
+
+
+
Validacion del Draft
+
+
+
    + @foreach (var item in ValidationItems) + { +
  • + + @item.Message +
  • + } +
+
+
+ +
+
+
Importes
+
+
+
+ Neto + @Document.NetAmount.ToString("N2") +
+
+ Impuestos + @Document.TaxAmount.ToString("N2") +
+
+ Total + @Document.Currency @Document.TotalAmount.ToString("N2") +
+
+
+ +
+
+
Acciones
+
+
+ + +
+ Guardar revision persiste los campos editables del Draft. Validar cambia el estado a Validated. +
+
+
+
+
+ } +
+ +@code { + [Parameter] public int Id { get; set; } + + private SalesDocumentDto? Document; + private bool IsLoading; + private bool IsSaving; + private string? ReviewObservations; + private List OriginDeliveryNotes = new(); + private SalesDocumentDraftValidationDto DraftValidation = new(); + + private bool IsDraft => Document?.Status == (int)SalesDocumentStatus.Draft; + private bool CanValidate => IsDraft && ValidationItems.All(x => x.IsValid); + private string DocumentNumber => string.IsNullOrWhiteSpace(Document?.InternalDocumentNumber) ? $"#{Document?.Id}" : Document.InternalDocumentNumber; + + private List ValidationItems => + [ + new(DraftValidation.HasDetails, "Tiene detalles"), + new(DraftValidation.HasValidAmounts, "Tiene importes validos"), + new(DraftValidation.HasCustomer, "Posee cliente asignado"), + new(DraftValidation.IsDraft, "Permanece en estado Draft") + ]; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + IsLoading = true; + var preview = await SalesDocumentService.GetDraftPreviewAsync(Id); + ApplyPreview(preview); + } + catch (Exception ex) + { + toastService.ShowError(ex.Message); + } + finally + { + IsLoading = false; + } + } + + private async Task SaveReviewAsync() + { + if (Document is null) + return; + + try + { + IsSaving = true; + var preview = await SalesDocumentService.UpdateDraftReviewAsync(Id, new SalesDocumentDraftReviewDto + { + IssueDate = Document.IssueDate, + AssociatedDocumentType = Document.AssociatedDocumentType, + AssociatedDocumentNumber = Document.AssociatedDocumentNumber, + AssociatedDocumentDate = Document.AssociatedDocumentDate, + Observations = ReviewObservations, + PeriodFrom = Document.PeriodFrom, + PeriodTo = Document.PeriodTo + }); + + ApplyPreview(preview); + toastService.ShowSuccess("Revision guardada correctamente."); + } + catch (Exception ex) + { + toastService.ShowError(ex.Message); + } + finally + { + IsSaving = false; + } + } + + private async Task ValidateDraftAsync() + { + try + { + IsSaving = true; + var preview = await SalesDocumentService.ValidateDraftAsync(Id); + ApplyPreview(preview); + toastService.ShowSuccess("Sales Document validado correctamente."); + } + catch (Exception ex) + { + toastService.ShowError(ex.Message); + } + finally + { + IsSaving = false; + } + } + + private void BackToList() => Navigation.NavigateTo("/salesdocuments"); + private void ViewDetail() => Navigation.NavigateTo($"/salesdocuments/{Id}"); + + private void ApplyPreview(SalesDocumentDraftPreviewDto? preview) + { + Document = preview?.Document; + ReviewObservations = Document?.Observations; + OriginDeliveryNotes = preview?.OriginDeliveryNotes ?? new List(); + DraftValidation = preview?.Validation ?? new SalesDocumentDraftValidationDto(); + } + + private static string FormatDate(DateTime? value) => value.HasValue ? value.Value.ToString("dd/MM/yyyy") : "-"; + + private static string GetDocumentTypeLabel(int value) => Enum.IsDefined(typeof(SalesDocumentType), value) + ? ((SalesDocumentType)value) switch + { + SalesDocumentType.Invoice => "Factura", + SalesDocumentType.DebitNote => "Nota de debito", + SalesDocumentType.CreditNote => "Nota de credito", + SalesDocumentType.CreditInvoice => "Factura credito", + SalesDocumentType.CreditDebitNote => "N/D credito", + SalesDocumentType.CreditCreditNote => "N/C credito", + _ => value.ToString() + } + : value.ToString(); + + private static string GetStatusLabel(int value) => Enum.IsDefined(typeof(SalesDocumentStatus), value) + ? ((SalesDocumentStatus)value) switch + { + SalesDocumentStatus.Draft => "Borrador", + SalesDocumentStatus.Validated => "Validado", + SalesDocumentStatus.Issued => "Emitido", + SalesDocumentStatus.Cancelled => "Anulado", + _ => value.ToString() + } + : value.ToString(); + + private static string GetCoverageTypeLabel(int value) => Enum.IsDefined(typeof(SalesDocumentCoverageType), value) + ? ((SalesDocumentCoverageType)value) switch + { + SalesDocumentCoverageType.Direct => "Directa", + SalesDocumentCoverageType.Capita => "Capita", + SalesDocumentCoverageType.Adjustment => "Ajuste", + SalesDocumentCoverageType.Manual => "Manual", + _ => value.ToString() + } + : value.ToString(); + + private static string GetOriginTypeLabel(string value) => value switch + { + "MANUAL" => "Manual", + "QUOTE" => "Presupuesto", + "ADJUSTMENT" => "Ajuste", + "CAPITA" => "Capita", + "DELIVERY_NOTE" => "Remito", + _ => string.IsNullOrWhiteSpace(value) ? "-" : value + }; + + private static string GetStatusBadge(int value) => value switch + { + (int)SalesDocumentStatus.Draft => "bg-secondary text-white", + (int)SalesDocumentStatus.Validated => "bg-info text-dark", + (int)SalesDocumentStatus.Issued => "bg-primary text-white", + (int)SalesDocumentStatus.Cancelled => "bg-danger text-white", + _ => "bg-light text-dark" + }; + + private sealed record ValidationItem(bool IsValid, string Message); +} diff --git a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor.css b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor.css new file mode 100644 index 0000000..542c8af --- /dev/null +++ b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentReview.razor.css @@ -0,0 +1,100 @@ +.sales-document-review { + color: var(--text-background); + padding-bottom: 2rem; +} + +.sales-document-review .card { + background-color: var(--background-highlight); + border-color: var(--background-highlight-light); +} + +.sales-document-review .card-header, +.sales-document-review .table-light { + background-color: var(--background-highlight-light); + color: var(--text-background); +} + +.sales-document-review .card-body, +.sales-document-review .table, +.sales-document-review .form-control { + color: var(--text-background); +} + +.sales-document-review .form-control { + background-color: var(--background); + border-color: var(--background-highlight-light); +} + +.origin-card { + background-color: var(--background); + border: 1px solid var(--background-highlight-light); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-height: 86px; + padding: 0.85rem; +} + +.origin-card strong { + color: var(--text-background); + font-size: 0.95rem; +} + +.origin-card small, +.origin-card span { + color: var(--text-background); + opacity: 0.68; +} + +.validation-list { + display: flex; + flex-direction: column; + gap: 0.65rem; + list-style: none; + margin: 0; + padding: 0; +} + +.validation-list li { + align-items: center; + background-color: var(--background); + border: 1px solid var(--background-highlight-light); + border-radius: 8px; + display: flex; + gap: 0.55rem; + padding: 0.7rem 0.8rem; +} + +.validation-list li.valid i { + color: #138f77; +} + +.validation-list li.invalid i { + color: #cf4435; +} + +.amount-row { + align-items: center; + border-bottom: 1px solid var(--background-highlight-light); + display: flex; + justify-content: space-between; + padding: 0.75rem 0; +} + +.amount-row:first-child { + padding-top: 0; +} + +.amount-row.total { + border-bottom: 0; + font-size: 1.05rem; + padding-bottom: 0; +} + +@media (max-width: 767.98px) { + .sales-document-review { + padding-left: 0; + padding-right: 0; + } +} diff --git a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocuments.razor b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocuments.razor index cd868ae..c2c78cd 100644 --- a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocuments.razor +++ b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocuments.razor @@ -100,6 +100,10 @@ @document.TotalAmount.ToString("N2") + @if (document.Status == (int)SalesDocumentStatus.Draft) + { + + } } @@ -203,6 +207,7 @@ private void Create() => Navigation.NavigateTo("/salesdocuments/create"); private void Detail(int id) => Navigation.NavigateTo($"/salesdocuments/{id}"); + private void Review(int id) => Navigation.NavigateTo($"/salesdocuments/{id}/review"); private void OnClear() { diff --git a/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs b/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs index b2d4d0a..dce6649 100644 --- a/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs +++ b/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs @@ -7,6 +7,9 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments { Task> SearchAsync(SalesDocumentSearchParams searchParams); Task GetByIdAsync(int id); + Task GetDraftPreviewAsync(int id); + Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto request); + Task ValidateDraftAsync(int id); Task CreateAsync(SalesDocumentCreateRequest request); Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request); Task> SearchDeliveryNoteCandidatesAsync( diff --git a/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs b/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs index 8c0b8d3..2eda360 100644 --- a/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs +++ b/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs @@ -102,6 +102,45 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments return await _http.GetFromJsonAsync($"/api/SalesDocument/{id}"); } + public async Task GetDraftPreviewAsync(int id) + { + return await _http.GetFromJsonAsync($"/api/SalesDocument/{id}/draft-preview"); + } + + public async Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var response = await _http.PutAsJsonAsync($"/api/SalesDocument/{id}/draft-review", request); + + if (!response.IsSuccessStatusCode) + { + var serverMessage = await response.Content.ReadAsStringAsync(); + throw new Exception(string.IsNullOrWhiteSpace(serverMessage) + ? "No se pudo guardar la revision del Sales Document." + : serverMessage); + } + + var result = await response.Content.ReadFromJsonAsync(); + return result ?? throw new Exception("Respuesta vacia del servidor."); + } + + public async Task ValidateDraftAsync(int id) + { + var response = await _http.PostAsync($"/api/SalesDocument/{id}/validate", null); + + if (!response.IsSuccessStatusCode) + { + var serverMessage = await response.Content.ReadAsStringAsync(); + throw new Exception(string.IsNullOrWhiteSpace(serverMessage) + ? "No se pudo validar el Sales Document." + : serverMessage); + } + + var result = await response.Content.ReadFromJsonAsync(); + return result ?? throw new Exception("Respuesta vacia del servidor."); + } + public async Task CreateAsync(SalesDocumentCreateRequest request) { ArgumentNullException.ThrowIfNull(request); -- 2.47.1