Merge pull request 'feat(sales): add sales document draft review and validation close #74' (#75) from feature/leandro/74-sales-document-draft-review into master
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 3m39s

Reviewed-on: #75
This commit is contained in:
Leandro Hernan Rojas 2026-06-11 23:08:50 +00:00
commit ab4f73f941
15 changed files with 1094 additions and 3 deletions

View File

@ -18,6 +18,9 @@ namespace Core.Interfaces
Task<SalesDocumentCreateResponse> CreateAsync(SalesDocumentCreateRequest request);
Task<SalesDocumentCreateResponse> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
Task<SalesDocumentDraftPreviewDto?> GetDraftPreviewAsync(int id);
Task<SalesDocumentDraftPreviewDto?> UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review);
Task<SalesDocumentDraftPreviewDto?> ValidateDraftAsync(int id);
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
int? customerId,
string? customerText,

View File

@ -317,6 +317,174 @@ namespace Core.Services
return _salesDocumentRepository.GetDtoByIdAsync(id);
}
public async Task<SalesDocumentDraftPreviewDto?> 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<SalesDocumentDraftPreviewDto?> 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<SalesDocumentDraftPreviewDto?> 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<DeliveryNoteSummaryDto> 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)

View File

@ -0,0 +1,9 @@
namespace Domain.Dtos.Sales
{
public class SalesDocumentDraftPreviewDto
{
public SalesDocumentDto Document { get; set; } = new();
public List<DeliveryNoteSummaryDto> OriginDeliveryNotes { get; set; } = new();
public SalesDocumentDraftValidationDto Validation { get; set; } = new();
}
}

View File

@ -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; }
}
}

View File

@ -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<string> Errors { get; set; } = new();
}
}

View File

@ -30,5 +30,7 @@ namespace Models.Interfaces
int page = 1,
int pageSize = 50);
Task<SalesDocumentDto?> GetDtoByIdAsync(int id);
Task<SalesDocumentDto?> UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review);
Task<SalesDocumentDto?> ValidateDraftAsync(int id);
}
}

View File

@ -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<SalesDocumentDto?> 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<SalesDocumentDto?> 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);
}
}
}

View File

@ -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

View File

@ -69,6 +69,79 @@ namespace phronCare.API.Controllers.Sales
}
}
[HttpGet("{id:int}/draft-preview")]
public async Task<ActionResult<SalesDocumentDraftPreviewDto>> 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<ActionResult<SalesDocumentDraftPreviewDto>> 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<ActionResult<SalesDocumentDraftPreviewDto>> 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<ActionResult<PagedResult<SalesDocumentDeliveryNoteCandidateDto>>> SearchDeliveryNoteCandidates(

View File

@ -9,10 +9,18 @@
<div class="container mt-4" style="zoom:.8;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0">Sales Document</h3>
<div class="d-flex flex-wrap gap-2">
@if (Document?.Status == (int)SalesDocumentStatus.Draft)
{
<button type="button" class="btn btn-success rounded-pill" @onclick="ReviewDraft">
<i class="fas fa-clipboard-check me-1"></i> Revisar draft
</button>
}
<button type="button" class="btn btn-secondary rounded-pill" @onclick="BackToList">
<i class="fas fa-arrow-left me-1"></i> Volver
</button>
</div>
</div>
@if (IsLoading)
{
@ -198,6 +206,7 @@
}
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
private void ReviewDraft() => Navigation.NavigateTo($"/salesdocuments/{Id}/review");
private string GetOriginSummary()
{

View File

@ -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
<div class="sales-document-review container-fluid" style="zoom:.8;">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h3 class="mb-1">Revision administrativa</h3>
<div class="text-muted">Sales Document Draft Review & Validation</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary rounded-pill" @onclick="BackToList">
<i class="fas fa-arrow-left me-1"></i> Volver
</button>
<button type="button" class="btn btn-outline-primary rounded-pill" @onclick="ViewDetail" disabled="@(Document is null)">
<i class="fas fa-eye me-1"></i> Detalle
</button>
</div>
</div>
@if (IsLoading)
{
<div class="card shadow-sm">
<div class="card-body text-center text-muted py-4">Cargando...</div>
</div>
}
else if (Document is null)
{
<div class="alert alert-warning">No se pudo cargar el Sales Document.</div>
}
else
{
<div class="alert @(IsDraft ? "alert-info" : "alert-secondary")">
@if (IsDraft)
{
<span>Este documento esta en Draft y puede revisarse visualmente. Guardar y validar requieren los endpoints de backend fuera del alcance UI-only.</span>
}
else
{
<span>Este documento no esta en Draft. La revision queda en modo solo lectura.</span>
}
</div>
<div class="row g-3">
<div class="col-xl-8">
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Cabecera del documento</h5>
<span class="badge @GetStatusBadge(Document.Status)">@GetStatusLabel(Document.Status)</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label fw-semibold mb-1">Documento</label>
<div class="form-control bg-white">@DocumentNumber</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-1">Tipo</label>
<div class="form-control bg-white">@GetDocumentTypeLabel(Document.DocumentType)</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-1">Emision</label>
<div class="form-control bg-white">@FormatDate(Document.IssueDate)</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-1">Moneda</label>
<div class="form-control bg-white">@Document.Currency</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold mb-1">Cliente</label>
<div class="form-control bg-white">@Document.CustomerName</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold mb-1">Bill To Customer</label>
<div class="form-control bg-white">@Document.BillToCustomerName</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold mb-1">Presupuesto</label>
<div class="form-control bg-white">@(Document.QuoteId?.ToString() ?? "-")</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold mb-1">Periodo desde</label>
<div class="form-control bg-white">@FormatDate(Document.PeriodFrom)</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold mb-1">Periodo hasta</label>
<div class="form-control bg-white">@FormatDate(Document.PeriodTo)</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold mb-1">Cotizacion</label>
<div class="form-control bg-white">@Document.ExchangeRate.ToString("N4")</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold mb-1">Observaciones</label>
<textarea class="form-control" rows="4" @bind="ReviewObservations" disabled="@(!IsDraft)"></textarea>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header">
<h5 class="mb-0">Remitos origen</h5>
</div>
<div class="card-body">
@if (OriginDeliveryNotes.Any())
{
<div class="row g-2">
@foreach (var item in OriginDeliveryNotes)
{
<div class="col-md-6">
<div class="origin-card">
<strong>@item.DeliveryNoteNumber</strong>
<small>Remito ID @item.Id</small>
<span>@FormatDate(item.IssueDate)</span>
</div>
</div>
}
</div>
}
else
{
<div class="text-muted">No se detectaron remitos origen en el snapshot del documento.</div>
}
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header">
<h5 class="mb-0">Items</h5>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Origen</th>
<th>Descripcion</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Unitario</th>
<th class="text-end">Neto</th>
<th class="text-end">Impuesto</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody>
@if (Document.Details.Any())
{
@foreach (var item in Document.Details.OrderBy(x => x.LineNumber))
{
<tr>
<td class="text-center">@item.LineNumber</td>
<td>@GetOriginTypeLabel(item.OriginType)</td>
<td>@item.Description</td>
<td class="text-end">@item.Quantity.ToString("N2")</td>
<td class="text-end">@item.UnitPrice.ToString("N2")</td>
<td class="text-end">@item.NetAmount.ToString("N2")</td>
<td class="text-end">@item.TaxAmount.ToString("N2")</td>
<td class="text-end">@item.TotalAmount.ToString("N2")</td>
</tr>
}
}
else
{
<tr><td colspan="8" class="text-center text-muted py-4">Sin items.</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0">Coverage</h5>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Tipo</th>
<th>Presupuesto</th>
<th>Quote Detail</th>
<th class="text-end">Porcentaje</th>
<th class="text-end">Importe</th>
<th>Desde</th>
<th>Hasta</th>
<th>Notas</th>
</tr>
</thead>
<tbody>
@if (Document.Coverage.Any())
{
@foreach (var coverage in Document.Coverage)
{
<tr>
<td>@GetCoverageTypeLabel(coverage.CoverageType)</td>
<td>@coverage.QuoteId</td>
<td>@(coverage.QuoteDetailId?.ToString() ?? "-")</td>
<td class="text-end">@(coverage.CoveragePercentage?.ToString("N2") ?? "-")</td>
<td class="text-end">@(coverage.CoverageAmount?.ToString("N2") ?? "-")</td>
<td>@FormatDate(coverage.PeriodFrom)</td>
<td>@FormatDate(coverage.PeriodTo)</td>
<td>@(string.IsNullOrWhiteSpace(coverage.Notes) ? "-" : coverage.Notes)</td>
</tr>
}
}
else
{
<tr><td colspan="8" class="text-center text-muted py-4">Sin coverage informado.</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card shadow-sm mb-3">
<div class="card-header">
<h5 class="mb-0">Validacion del Draft</h5>
</div>
<div class="card-body">
<ul class="validation-list">
@foreach (var item in ValidationItems)
{
<li class="@(item.IsValid ? "valid" : "invalid")">
<i class="fas @(item.IsValid ? "fa-check-circle" : "fa-circle-exclamation")"></i>
<span>@item.Message</span>
</li>
}
</ul>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header">
<h5 class="mb-0">Importes</h5>
</div>
<div class="card-body">
<div class="amount-row">
<span>Neto</span>
<strong>@Document.NetAmount.ToString("N2")</strong>
</div>
<div class="amount-row">
<span>Impuestos</span>
<strong>@Document.TaxAmount.ToString("N2")</strong>
</div>
<div class="amount-row total">
<span>Total</span>
<strong>@Document.Currency @Document.TotalAmount.ToString("N2")</strong>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<h5 class="mb-0">Acciones</h5>
</div>
<div class="card-body d-grid gap-2">
<button type="button" class="btn btn-primary rounded-pill" @onclick="SaveReviewAsync" disabled="@(!IsDraft || IsSaving)">
<i class="fas fa-save me-1"></i> Guardar revision
</button>
<button type="button" class="btn btn-success rounded-pill" @onclick="ValidateDraftAsync" disabled="@(!CanValidate || IsSaving)">
<i class="fas fa-check-circle me-1"></i> Validar documento
</button>
<div class="small text-muted mt-2">
Guardar revision persiste los campos editables del Draft. Validar cambia el estado a Validated.
</div>
</div>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int Id { get; set; }
private SalesDocumentDto? Document;
private bool IsLoading;
private bool IsSaving;
private string? ReviewObservations;
private List<DeliveryNoteSummaryDto> 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<ValidationItem> 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<DeliveryNoteSummaryDto>();
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);
}

View File

@ -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;
}
}

View File

@ -100,6 +100,10 @@
<td>@document.TotalAmount.ToString("N2")</td>
<td class="text-center align-middle">
<button class="btn btn-link btn-lg p-0 text-primary ms-2" title="Ver detalle" @onclick="() => Detail(document.Id)"><i class="fas fa-eye"></i></button>
@if (document.Status == (int)SalesDocumentStatus.Draft)
{
<button class="btn btn-link btn-lg p-0 text-success ms-2" title="Revisar draft" @onclick="() => Review(document.Id)"><i class="fas fa-clipboard-check"></i></button>
}
</td>
</tr>
}
@ -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()
{

View File

@ -7,6 +7,9 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
{
Task<PagedResult<SalesDocumentSummaryDto>> SearchAsync(SalesDocumentSearchParams searchParams);
Task<SalesDocumentDto?> GetByIdAsync(int id);
Task<SalesDocumentDraftPreviewDto?> GetDraftPreviewAsync(int id);
Task<SalesDocumentDraftPreviewDto> UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto request);
Task<SalesDocumentDraftPreviewDto> ValidateDraftAsync(int id);
Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request);
Task<SalesDocumentDto> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(

View File

@ -102,6 +102,45 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
return await _http.GetFromJsonAsync<SalesDocumentDto>($"/api/SalesDocument/{id}");
}
public async Task<SalesDocumentDraftPreviewDto?> GetDraftPreviewAsync(int id)
{
return await _http.GetFromJsonAsync<SalesDocumentDraftPreviewDto>($"/api/SalesDocument/{id}/draft-preview");
}
public async Task<SalesDocumentDraftPreviewDto> 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<SalesDocumentDraftPreviewDto>();
return result ?? throw new Exception("Respuesta vacia del servidor.");
}
public async Task<SalesDocumentDraftPreviewDto> 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<SalesDocumentDraftPreviewDto>();
return result ?? throw new Exception("Respuesta vacia del servidor.");
}
public async Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request)
{
ArgumentNullException.ThrowIfNull(request);