diff --git a/Core/Interfaces/ISalesDocumentDom.cs b/Core/Interfaces/ISalesDocumentDom.cs index 495ad66..57d9448 100644 --- a/Core/Interfaces/ISalesDocumentDom.cs +++ b/Core/Interfaces/ISalesDocumentDom.cs @@ -17,6 +17,16 @@ namespace Core.Interfaces int pageSize = 50); Task CreateAsync(SalesDocumentCreateRequest request); + Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request); + Task> SearchDeliveryNoteCandidatesAsync( + int? customerId, + string? customerText, + string? deliveryNoteNumber, + int? quoteId, + DateTime? issueDateFrom, + DateTime? issueDateTo, + int page = 1, + int pageSize = 50); Task GetDtoByIdAsync(int id); } } diff --git a/Core/Services/SalesDocumentService.cs b/Core/Services/SalesDocumentService.cs index 8ea4839..958a180 100644 --- a/Core/Services/SalesDocumentService.cs +++ b/Core/Services/SalesDocumentService.cs @@ -4,6 +4,7 @@ using Domain.Dtos.Sales; using Domain.Entities; using Domain.Generics; using Models.Interfaces; +using System.Text.Json; namespace Core.Services { @@ -34,6 +35,184 @@ namespace Core.Services pageSize); } + + public Task> SearchDeliveryNoteCandidatesAsync( + int? customerId, + string? customerText, + string? deliveryNoteNumber, + int? quoteId, + DateTime? issueDateFrom, + DateTime? issueDateTo, + int page = 1, + int pageSize = 50) + { + return _salesDocumentRepository.SearchDeliveryNoteCandidatesAsync( + customerId, + customerText, + deliveryNoteNumber, + quoteId, + issueDateFrom, + issueDateTo, + page, + pageSize); + } + + public async Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var deliveryNoteIds = request.DeliveryNoteIds + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (deliveryNoteIds.Count == 0) + throw new InvalidOperationException("Debe seleccionar al menos un remito emitido."); + + if (string.IsNullOrWhiteSpace(request.Currency)) + throw new ArgumentException("La moneda es obligatoria.", nameof(request.Currency)); + + var deliveryNotes = await _salesDocumentRepository.GetDeliveryNotesForSalesDocumentAsync(deliveryNoteIds); + + if (deliveryNotes.Count != deliveryNoteIds.Count) + throw new InvalidOperationException("Uno o más remitos seleccionados no existen."); + + var notIssued = deliveryNotes.Where(x => !string.Equals(x.Status, "Emitido", StringComparison.OrdinalIgnoreCase)).ToList(); + if (notIssued.Count > 0) + throw new InvalidOperationException("Solo se pueden facturar remitos en estado Emitido."); + + var alreadyAssociated = deliveryNotes.Where(x => x.SalesInvoiceId.HasValue && x.SalesInvoiceId.Value > 0).ToList(); + if (alreadyAssociated.Count > 0) + throw new InvalidOperationException("Uno o más remitos ya están asociados a un Sales Document."); + + var fiscalCustomerIds = deliveryNotes.Select(x => x.CustomerId).Distinct().ToList(); + if (fiscalCustomerIds.Count != 1) + throw new InvalidOperationException("No se pueden agrupar remitos de distintos clientes fiscales."); + + if (deliveryNotes.Any(x => x.Items.Count == 0)) + throw new InvalidOperationException("Todos los remitos seleccionados deben tener ítems."); + + var now = DateTime.Now; + var details = new List(); + var coverages = new List(); + var lineNumber = 1; + + foreach (var deliveryNote in deliveryNotes) + { + foreach (var item in deliveryNote.Items) + { + var unitPrice = item.ApprovedUnitPrice ?? item.OriginalUnitPrice ?? 0; + var approvedAmount = item.ApprovedAmount ?? (unitPrice * item.Quantity); + + if (approvedAmount <= 0) + throw new InvalidOperationException($"El remito {deliveryNote.DeliveryNoteNumber} contiene ítems sin precio aprobado."); + + var originSnapshot = JsonSerializer.Serialize(new + { + deliveryNoteId = deliveryNote.Id, + deliveryNoteNumber = deliveryNote.DeliveryNoteNumber, + deliveryNoteIssueDate = deliveryNote.IssueDate, + quoteId = deliveryNote.QuoteId, + quoteNumber = deliveryNote.QuoteNumber, + customerId = deliveryNote.CustomerId, + customerName = deliveryNote.CustomerName, + deliveryNoteExtraInfo = deliveryNote.ExtraInfoJson + }); + + details.Add(new ESalesDocumentDetail + { + LineNumber = lineNumber++, + OriginType = SalesDocumentOriginType.DeliveryNote.ToStorageCode(), + OriginId = deliveryNote.Id, + QuoteDetailId = item.QuoteDetailId, + ProductId = item.ProductId, + Description = item.Description.Trim(), + Quantity = item.Quantity, + AuthorizedUnitPrice = unitPrice, + AuthorizedAmount = approvedAmount, + BilledPercentage = 100, + UnitPrice = unitPrice, + NetAmount = approvedAmount, + TaxAmount = 0, + TotalAmount = approvedAmount, + OriginSnapshotJson = originSnapshot, + Createdat = now + }); + + if (deliveryNote.QuoteId.HasValue) + { + coverages.Add(new ESalesDocumentCoverage + { + QuoteId = deliveryNote.QuoteId.Value, + QuoteDetailId = item.QuoteDetailId, + CoverageType = (int)SalesDocumentCoverageType.Direct, + CoveragePercentage = 100, + CoverageAmount = approvedAmount, + PeriodFrom = request.PeriodFrom, + PeriodTo = request.PeriodTo, + Notes = $"Coverage desde remito {deliveryNote.DeliveryNoteNumber}", + Createdat = now + }); + } + } + } + + var totalAmount = details.Sum(x => x.TotalAmount); + if (totalAmount <= 0) + throw new InvalidOperationException("El total del documento debe ser mayor a cero."); + + var operations = deliveryNotes.Select(x => new SalesDocumentDeliveryNoteOperationSnapshotDto + { + DeliveryNoteId = x.Id, + DeliveryNoteNumber = x.DeliveryNoteNumber, + QuoteId = x.QuoteId, + QuoteNumber = x.QuoteNumber, + CustomerId = x.CustomerId, + CustomerName = x.CustomerName, + IssueDate = x.IssueDate, + Amount = x.Items.Sum(i => i.ApprovedAmount ?? ((i.ApprovedUnitPrice ?? i.OriginalUnitPrice ?? 0) * i.Quantity)), + Coverage = x.CustomerName + }).ToList(); + + var extraInfoJson = JsonSerializer.Serialize(new + { + source = "DeliveryNotes", + grouped = deliveryNotes.Count > 1, + coverageDefinition = "Entidad financiadora / pagadora responsable", + operations + }); + + var entity = new ESalesDocument + { + DocumentType = request.DocumentType, + Status = (int)SalesDocumentStatus.Draft, + QuoteId = deliveryNotes.Count == 1 ? deliveryNotes[0].QuoteId : null, + CustomerId = fiscalCustomerIds[0], + BillToCustomerId = fiscalCustomerIds[0], + IssueDate = request.IssueDate ?? now, + Currency = request.Currency.Trim(), + ExchangeRate = request.ExchangeRate <= 0 ? 1 : request.ExchangeRate, + NetAmount = totalAmount, + TaxAmount = 0, + TotalAmount = totalAmount, + Observations = request.Observations, + ExtraInfoJson = extraInfoJson, + PeriodFrom = request.PeriodFrom, + PeriodTo = request.PeriodTo, + Createdat = now, + PhSSalesDocumentDetails = details, + PhSSalesDocumentCoverages = coverages + }; + + var created = await _salesDocumentRepository.CreateFromDeliveryNotesAsync(entity, deliveryNoteIds); + + return new SalesDocumentCreateResponse + { + Id = created.Id, + InternalDocumentNumber = created.InternalDocumentNumber + }; + } + public async Task CreateAsync(SalesDocumentCreateRequest request) { ArgumentNullException.ThrowIfNull(request); diff --git a/Domain/Dtos/Sales/DeliveryNoteItemDto.cs b/Domain/Dtos/Sales/DeliveryNoteItemDto.cs index 354dffa..bdf8c5d 100644 --- a/Domain/Dtos/Sales/DeliveryNoteItemDto.cs +++ b/Domain/Dtos/Sales/DeliveryNoteItemDto.cs @@ -15,6 +15,10 @@ namespace Domain.Dtos.Sales public int? QuoteDetailId { get; set; } public string Description { get; set; } = string.Empty; public decimal Quantity { get; set; } + public int? ProductId { get; set; } + public decimal? ApprovedUnitPrice { get; set; } + public decimal? ApprovedAmount { get; set; } + public decimal? OriginalUnitPrice { get; set; } public string Notes { get; set; } = string.Empty; public DateTime Createdat { get; set; } public DateTime? Modifiedat { get; set; } diff --git a/Domain/Dtos/Sales/SalesDocumentCreateFromDeliveryNotesRequest.cs b/Domain/Dtos/Sales/SalesDocumentCreateFromDeliveryNotesRequest.cs new file mode 100644 index 0000000..8787b3e --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentCreateFromDeliveryNotesRequest.cs @@ -0,0 +1,14 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentCreateFromDeliveryNotesRequest + { + public List DeliveryNoteIds { get; set; } = new(); + public int DocumentType { get; set; } = 1; + public DateTime? IssueDate { get; set; } + public string Currency { get; set; } = "ARS"; + public decimal ExchangeRate { get; set; } = 1; + public DateTime? PeriodFrom { get; set; } + public DateTime? PeriodTo { get; set; } + public string? Observations { get; set; } + } +} diff --git a/Domain/Dtos/Sales/SalesDocumentDeliveryNoteCandidateDto.cs b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteCandidateDto.cs new file mode 100644 index 0000000..6548f70 --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteCandidateDto.cs @@ -0,0 +1,17 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDeliveryNoteCandidateDto + { + public int Id { get; set; } + public string DeliveryNoteNumber { get; set; } = string.Empty; + public int? QuoteId { get; set; } + public string? QuoteNumber { get; set; } + public DateTime IssueDate { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public int ItemCount { get; set; } + public decimal ApprovedAmount { get; set; } + public string? ExtraInfoJson { get; set; } + } +} diff --git a/Domain/Dtos/Sales/SalesDocumentDeliveryNoteOperationSnapshotDto.cs b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteOperationSnapshotDto.cs new file mode 100644 index 0000000..6f1f255 --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteOperationSnapshotDto.cs @@ -0,0 +1,19 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDeliveryNoteOperationSnapshotDto + { + public int DeliveryNoteId { get; set; } + public string DeliveryNoteNumber { get; set; } = string.Empty; + public int? QuoteId { get; set; } + public string? QuoteNumber { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public DateTime IssueDate { get; set; } + public decimal Amount { get; set; } + public string? Patient { get; set; } + public string? Doctor { get; set; } + public string? Hospital { get; set; } + public DateTime? SurgeryDate { get; set; } + public string? Coverage { get; set; } + } +} diff --git a/Models/Interfaces/IPhSSalesDocumentRepository.cs b/Models/Interfaces/IPhSSalesDocumentRepository.cs index 7105487..f1d2b7e 100644 --- a/Models/Interfaces/IPhSSalesDocumentRepository.cs +++ b/Models/Interfaces/IPhSSalesDocumentRepository.cs @@ -18,6 +18,17 @@ namespace Models.Interfaces int pageSize = 50); Task CreateAsync(ESalesDocument entity); + Task CreateFromDeliveryNotesAsync(ESalesDocument entity, IReadOnlyCollection deliveryNoteIds); + Task> GetDeliveryNotesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds); + Task> SearchDeliveryNoteCandidatesAsync( + int? customerId, + string? customerText, + string? deliveryNoteNumber, + int? quoteId, + DateTime? issueDateFrom, + DateTime? issueDateTo, + int page = 1, + int pageSize = 50); Task GetDtoByIdAsync(int id); } } diff --git a/Models/Repositories/PhSDeliveryNoteRepository.cs b/Models/Repositories/PhSDeliveryNoteRepository.cs index 7102e7c..af8fcec 100644 --- a/Models/Repositories/PhSDeliveryNoteRepository.cs +++ b/Models/Repositories/PhSDeliveryNoteRepository.cs @@ -89,6 +89,7 @@ namespace Models.Repositories .Include(x => x.Customer) .Include(x => x.Quote) .Include(x => x.PhSDeliveryNoteDetails) + .ThenInclude(d => d.QuoteDetail) .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == id); @@ -101,6 +102,7 @@ namespace Models.Repositories .Include(x => x.Customer) .Include(x => x.Quote) .Include(x => x.PhSDeliveryNoteDetails) + .ThenInclude(d => d.QuoteDetail) .AsNoTracking() .FirstOrDefaultAsync(x => x.Deliverynotenumber == deliveryNoteNumber); @@ -113,6 +115,7 @@ namespace Models.Repositories .Include(x => x.Customer) .Include(x => x.Quote) .Include(x => x.PhSDeliveryNoteDetails) + .ThenInclude(d => d.QuoteDetail) .AsNoTracking() .Where(x => x.QuoteId == quoteId) .OrderByDescending(x => x.Issuedate) @@ -178,6 +181,10 @@ namespace Models.Repositories QuoteDetailId = source.QuoteDetailId, Description = source.Description??string.Empty, Quantity = source.Quantity, + ProductId = source.QuoteDetail?.ProductId, + ApprovedUnitPrice = source.QuoteDetail?.Approvedunitprice, + ApprovedAmount = source.QuoteDetail?.Approvedamount, + OriginalUnitPrice = source.QuoteDetail?.Unitprice, Notes = source.Notes, Createdat = source.Createdat, Modifiedat = source.Modifiedat diff --git a/Models/Repositories/PhSSalesDocumentRepository.cs b/Models/Repositories/PhSSalesDocumentRepository.cs index 4434b0a..d963251 100644 --- a/Models/Repositories/PhSSalesDocumentRepository.cs +++ b/Models/Repositories/PhSSalesDocumentRepository.cs @@ -119,6 +119,166 @@ namespace Models.Repositories return EntityMapper.MapEntity(mapped); } + + public async Task> SearchDeliveryNoteCandidatesAsync( + int? customerId, + string? customerText, + string? deliveryNoteNumber, + int? quoteId, + DateTime? issueDateFrom, + DateTime? issueDateTo, + int page = 1, + int pageSize = 50) + { + page = page <= 0 ? 1 : page; + pageSize = pageSize <= 0 ? 50 : pageSize; + + var query = _context.PhSDeliveryNotes + .Include(x => x.Customer) + .Include(x => x.Quote) + .Include(x => x.PhSDeliveryNoteDetails) + .ThenInclude(d => d.QuoteDetail) + .AsNoTracking() + .Where(x => x.Status == "Emitido" && x.SalesinvoiceId == null) + .AsQueryable(); + + if (customerId.HasValue && customerId.Value > 0) + query = query.Where(x => x.CustomerId == customerId.Value); + else if (!string.IsNullOrWhiteSpace(customerText)) + query = query.Where(x => (x.Customer.Name ?? string.Empty).Contains(customerText.Trim())); + + if (!string.IsNullOrWhiteSpace(deliveryNoteNumber)) + query = query.Where(x => x.Deliverynotenumber.Contains(deliveryNoteNumber.Trim())); + + if (quoteId.HasValue && quoteId.Value > 0) + query = query.Where(x => x.QuoteId == quoteId.Value); + + if (issueDateFrom.HasValue) + query = query.Where(x => x.Issuedate >= issueDateFrom.Value); + + if (issueDateTo.HasValue) + query = query.Where(x => x.Issuedate <= issueDateTo.Value); + + var totalItems = await query.CountAsync(); + + var items = await query + .OrderByDescending(x => x.Issuedate) + .ThenByDescending(x => x.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new SalesDocumentDeliveryNoteCandidateDto + { + Id = x.Id, + DeliveryNoteNumber = x.Deliverynotenumber, + QuoteId = x.QuoteId, + QuoteNumber = x.Quote != null ? x.Quote.Quotenumber : null, + IssueDate = x.Issuedate, + CustomerId = x.CustomerId, + CustomerName = x.Customer.Name ?? string.Empty, + Status = x.Status, + ItemCount = x.PhSDeliveryNoteDetails.Count, + ApprovedAmount = x.PhSDeliveryNoteDetails.Sum(d => d.QuoteDetail != null + ? (d.QuoteDetail.Approvedamount ?? ((d.QuoteDetail.Approvedunitprice ?? d.QuoteDetail.Unitprice) * d.Quantity)) + : 0), + ExtraInfoJson = x.ExtrainfoJson + }) + .ToListAsync(); + + return new PagedResult + { + Items = items, + TotalItems = totalItems, + Page = page, + PageSize = pageSize + }; + } + + public async Task> GetDeliveryNotesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds) + { + var ids = deliveryNoteIds.Distinct().ToList(); + + var entities = await _context.PhSDeliveryNotes + .Include(x => x.Customer) + .Include(x => x.Quote) + .Include(x => x.PhSDeliveryNoteDetails) + .ThenInclude(d => d.QuoteDetail) + .AsNoTracking() + .Where(x => ids.Contains(x.Id)) + .OrderBy(x => x.Issuedate) + .ThenBy(x => x.Id) + .ToListAsync(); + + return entities.Select(MapDeliveryNoteDto).ToList(); + } + + public async Task CreateFromDeliveryNotesAsync(ESalesDocument entity, IReadOnlyCollection deliveryNoteIds) + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + + var mapped = EntityMapper.MapEntity(entity); + await _context.PhSSalesDocuments.AddAsync(mapped); + await _context.SaveChangesAsync(); + + var ids = deliveryNoteIds.Distinct().ToList(); + var deliveryNotes = await _context.PhSDeliveryNotes + .Where(x => ids.Contains(x.Id)) + .ToListAsync(); + + foreach (var deliveryNote in deliveryNotes) + { + deliveryNote.SalesinvoiceId = mapped.Id; + deliveryNote.Modifiedat = DateTime.Now; + } + + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); + + return EntityMapper.MapEntity(mapped); + } + + private static DeliveryNoteDto MapDeliveryNoteDto(PhSDeliveryNote source) + { + return new DeliveryNoteDto + { + Id = source.Id, + DeliveryNoteNumber = source.Deliverynotenumber, + CustomerName = source.Customer?.Name ?? string.Empty, + QuoteId = source.QuoteId, + QuoteNumber = source.Quote?.Quotenumber, + SalesInvoiceId = source.SalesinvoiceId, + IssueDate = source.Issuedate, + CustomerId = source.CustomerId, + Status = source.Status, + Observations = source.Observations, + ExtraInfoJson = source.ExtrainfoJson, + PrintCount = source.Printcount, + CreatedAt = source.Createdat, + ModifiedAt = source.Modifiedat, + Items = source.PhSDeliveryNoteDetails + .OrderBy(d => d.LineNumber) + .ThenBy(d => d.Id) + .Select(d => new DeliveryNoteItemDto + { + Id = d.Id, + DeliverynoteId = d.DeliverynoteId, + LineNumber = d.LineNumber, + OriginType = d.OriginType, + OriginId = d.OriginId, + QuoteDetailId = d.QuoteDetailId, + Description = d.Description ?? string.Empty, + Quantity = d.Quantity, + ProductId = d.QuoteDetail?.ProductId, + ApprovedUnitPrice = d.QuoteDetail?.Approvedunitprice, + ApprovedAmount = d.QuoteDetail?.Approvedamount, + OriginalUnitPrice = d.QuoteDetail?.Unitprice, + Notes = d.Notes, + Createdat = d.Createdat, + Modifiedat = d.Modifiedat + }) + .ToList() + }; + } + public async Task GetDtoByIdAsync(int id) { var entity = await _context.PhSSalesDocuments diff --git a/phronCare.API/Controllers/Sales/SalesDocumentController.cs b/phronCare.API/Controllers/Sales/SalesDocumentController.cs index 27cb741..dca3cfa 100644 --- a/phronCare.API/Controllers/Sales/SalesDocumentController.cs +++ b/phronCare.API/Controllers/Sales/SalesDocumentController.cs @@ -69,6 +69,70 @@ namespace phronCare.API.Controllers.Sales } } + + [HttpGet("delivery-note-candidates")] + public async Task>> SearchDeliveryNoteCandidates( + [FromQuery] int? customerId, + [FromQuery] string? customerText, + [FromQuery] string? deliveryNoteNumber, + [FromQuery] int? quoteId, + [FromQuery] DateTime? issueDateFrom, + [FromQuery] DateTime? issueDateTo, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + try + { + var result = await _salesDocumentService.SearchDeliveryNoteCandidatesAsync( + customerId, + customerText, + deliveryNoteNumber, + quoteId, + issueDateFrom, + issueDateTo, + page, + pageSize); + + return Ok(result); + } + catch (Exception ex) + { + var methodName = MethodBase.GetCurrentMethod()?.Name ?? "UnknownMethod"; + return StatusCode(500, $"{methodName} Message: {ex.Message}"); + } + } + + [HttpPost("from-delivery-notes")] + public async Task> CreateFromDeliveryNotes([FromBody] SalesDocumentCreateFromDeliveryNotesRequest request) + { + try + { + if (request == null) + return BadRequest("El payload no puede ser nulo."); + + var created = await _salesDocumentService.CreateFromDeliveryNotesAsync(request); + var salesDocument = await _salesDocumentService.GetDtoByIdAsync(created.Id); + + if (salesDocument == null) + return StatusCode(500, $"No se pudo recuperar el Sales Document creado con ID {created.Id}."); + + return CreatedAtAction(nameof(GetById), new { id = salesDocument.Id }, salesDocument); + } + 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] public async Task> Create([FromBody] SalesDocumentCreateRequest request) { diff --git a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor index b704257..49e7c1f 100644 --- a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor +++ b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor @@ -1,388 +1,252 @@ @page "/salesdocuments/create" -@using System.ComponentModel.DataAnnotations -@using Blazored.Typeahead @using Domain.Constants -@using Domain.Dtos @using Domain.Dtos.Sales -@using Domain.Entities -@using phronCare.UIBlazor.Services.Lookups +@using Domain.Generics @using phronCare.UIBlazor.Services.Sales.SalesDocuments @inject NavigationManager Navigation @inject ISalesDocumentService SalesDocumentService -@inject ISalesLookupService SalesLookupService @inject IToastService toastService - - - - -
-
-
-

Nuevo Sales Document

-
-
-
-
- - - -
-
- - - @foreach (var item in DocumentTypeOptions) - { - - } - -
-
- - - -
-
- - -
-
- - - -
-
- -
-
- - - @item.Nombre - @item.Nombre - - -
-
- - - @item.Nombre - @item.Nombre - - -
-
- -
-
- - - @foreach (var item in CoverageTypeOptions) - { - - } - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
-
+
+
+
+

Nuevo Sales Document desde remitos

+
+
+ Sales Document representa el documento comercial facturable. No emite comprobante fiscal ni integra ARCA/AFIP. +
-
-
-
Detalles
- +
-
- @if (Items.Any()) - { -
- - - - - - - - - - - - - - - - - @foreach (var item in Items) - { - - - - - - - - - - - - - } - -
#OrigenOrigin IDQuote detailDescripciónCantidadUnitarioImpuestoTotal
@item.LineNumber - - @foreach (var option in OriginTypeOptions) - { - - } - - - - - - - - - - - - - - @GetItemTotal(item).ToString("N2") - -
-
- } - else - { -
No hay ítems cargados.
- } -
-
- -
- -
- + +
+
+
Remitos emitidos pendientes
+ Seleccionados: @SelectedIds.Count +
+
+ @if (Candidates.Items.Any()) + { +
+ + + + + + + + + + + + + + @foreach (var item in Candidates.Items) + { + + + + + + + + + + } + +
RemitoFechaCliente fiscalPresupuestoÍtemsImporte aprobado
+ + @item.DeliveryNoteNumber@item.IssueDate.ToString("dd/MM/yyyy")@item.CustomerName@(item.QuoteNumber ?? item.QuoteId?.ToString() ?? "-")@item.ItemCount@item.ApprovedAmount.ToString("N2")
+
+ } + else + { +
Buscá remitos emitidos pendientes de facturación.
+ } +
+
+ +
+
Resumen comercial
+
+ @if (SelectedCandidates.Any()) + { +
+
+ Cliente fiscal:
+ @SelectedCustomerLabel +
+
+ Remitos:
@SelectedCandidates.Count +
+
+ Total:
@SelectedTotal.ToString("N2") +
+
+ Validación:
+ @if (HasSingleFiscalCustomer) + { + Cliente único + } + else + { + Clientes fiscales distintos + } +
+
+
+ + +
+ } + else + { +
Seleccioná uno o más remitos para ver el resumen.
+ } +
+
+ +
+ + +
+
@code { - private SalesDocumentCreatePageModel Model = new() - { - IssueDate = DateTime.Today, - DocumentType = (int)SalesDocumentType.Invoice, - Currency = "ARS", - ExchangeRate = 1, - CoverageType = (int)SalesDocumentCoverageType.Manual, - CoveragePercentage = 100 - }; - - private ELookUpItem? SelectedCustomer; - private ELookUpItem? SelectedBillToCustomer; - private List Items = new(); + private string? CustomerText; + private string? DeliveryNoteNumber; + private int? QuoteId; + private DateTime? IssueDateFrom; + private DateTime? IssueDateTo; + private string? Observations; + private bool IsLoading; private bool IsSaving; + private PagedResult Candidates = new(); + private readonly HashSet SelectedIds = new(); + private readonly Dictionary SelectedMap = new(); - private static readonly List DocumentTypeOptions = Enum.GetValues() - .Select(x => new SelectOption((int)x, GetDocumentTypeLabel((int)x))) - .ToList(); + private List SelectedCandidates => SelectedMap.Values.OrderBy(x => x.IssueDate).ThenBy(x => x.Id).ToList(); + private bool HasSingleFiscalCustomer => SelectedCandidates.Select(x => x.CustomerId).Distinct().Count() <= 1; + private bool CanCreate => SelectedIds.Count > 0 && HasSingleFiscalCustomer && SelectedTotal > 0; + private decimal SelectedTotal => SelectedCandidates.Sum(x => x.ApprovedAmount); + private string SelectedCustomerLabel => SelectedCandidates.FirstOrDefault()?.CustomerName ?? "-"; - private static readonly List CoverageTypeOptions = Enum.GetValues() - .Select(x => new SelectOption((int)x, GetCoverageTypeLabel((int)x))) - .ToList(); - - private static readonly List OriginTypeOptions = Enum.GetValues() - .Select(x => new SelectOption((int)x, GetOriginTypeLabel(x))) - .ToList(); - - protected override void OnInitialized() + private async Task SearchCandidatesAsync() { - AddItem(); - } - - private void AddItem() - { - Items.Add(new SalesDocumentItemRow + try { - LineNumber = Items.Count + 1, - OriginType = (int)SalesDocumentOriginType.Manual, - Quantity = 1, - UnitPrice = 0, - TaxAmount = 0 - }); - } - - private void RemoveItem(SalesDocumentItemRow item) - { - if (Items.Remove(item)) - ReindexItems(); - } - - private void ReindexItems() - { - for (var i = 0; i < Items.Count; i++) - Items[i].LineNumber = i + 1; - } - - private Task OnCustomerSelected(ELookUpItem? customer) - { - SelectedCustomer = customer; - Model.CustomerId = customer?.Id; - - if (SelectedBillToCustomer is null && customer is not null) - { - SelectedBillToCustomer = customer; - Model.BillToCustomerId = customer.Id; + IsLoading = true; + Candidates = await SalesDocumentService.SearchDeliveryNoteCandidatesAsync( + null, + CustomerText, + DeliveryNoteNumber, + QuoteId, + IssueDateFrom, + IssueDateTo, + 1, + 50); } - - return Task.CompletedTask; - } - - private Task OnBillToCustomerSelected(ELookUpItem? customer) - { - SelectedBillToCustomer = customer; - Model.BillToCustomerId = customer?.Id; - return Task.CompletedTask; - } - - private string? ValidateBeforeSave() - { - if (Model.CustomerId is null or <= 0) - return "Debe seleccionar un cliente."; - - if (Model.BillToCustomerId is null or <= 0) - return "Debe seleccionar un cliente de facturación."; - - if (Model.QuoteId is null or <= 0) - return "Debe informar un Presupuesto ID para coverage."; - - if (Items.Count == 0) - return "Debe incluir al menos un detail."; - - if (Items.Any(x => string.IsNullOrWhiteSpace(x.Description))) - return "Todos los detalles deben tener descripción."; - - if (Items.Any(x => x.Quantity <= 0)) - return "Todos los detalles deben tener cantidad mayor a cero."; - - if (Items.Any(x => x.UnitPrice < 0 || x.TaxAmount < 0)) - return "Los importes no pueden ser negativos."; - - if (Items.Any(x => x.OriginType != (int)SalesDocumentOriginType.Manual - && (!x.OriginId.HasValue || x.OriginId.Value <= 0) - && (!x.QuoteDetailId.HasValue || x.QuoteDetailId.Value <= 0))) - return "Los detalles no manuales deben informar Origin ID o Quote detail."; - - if (Items.Sum(GetItemTotal) <= 0) - return "El total del documento debe ser mayor a cero."; - - return null; - } - - private async Task HandleValidSubmit() - { - var validationError = ValidateBeforeSave(); - if (!string.IsNullOrWhiteSpace(validationError)) + catch (Exception ex) { - toastService.ShowError(validationError); + toastService.ShowError(ex.Message); + } + finally + { + IsLoading = false; + } + } + + private void ToggleSelection(SalesDocumentDeliveryNoteCandidateDto item, ChangeEventArgs args) + { + var selected = args.Value is bool value && value; + if (selected) + { + SelectedIds.Add(item.Id); + SelectedMap[item.Id] = item; + } + else + { + SelectedIds.Remove(item.Id); + SelectedMap.Remove(item.Id); + } + } + + private void ClearFilters() + { + CustomerText = null; + DeliveryNoteNumber = null; + QuoteId = null; + IssueDateFrom = null; + IssueDateTo = null; + Candidates = new PagedResult(); + SelectedIds.Clear(); + SelectedMap.Clear(); + } + + private async Task CreateAsync() + { + if (!CanCreate) + { + toastService.ShowError("Debe seleccionar remitos pendientes de un único cliente fiscal."); return; } try { IsSaving = true; - ReindexItems(); - - var request = new SalesDocumentCreateRequest + var created = await SalesDocumentService.CreateFromDeliveryNotesAsync(new SalesDocumentCreateFromDeliveryNotesRequest { - DocumentType = Model.DocumentType, - QuoteId = Model.QuoteId, - CustomerId = Model.CustomerId!.Value, - BillToCustomerId = Model.BillToCustomerId!.Value, - IssueDate = Model.IssueDate, - Currency = Model.Currency.Trim(), - ExchangeRate = Model.ExchangeRate <= 0 ? 1 : Model.ExchangeRate, - Observations = Model.Observations, - PeriodFrom = Model.PeriodFrom, - PeriodTo = Model.PeriodTo, - Details = Items.Select(x => - { - var netAmount = GetItemNet(x); - var totalAmount = GetItemTotal(x); - return new SalesDocumentCreateDetailRequest - { - LineNumber = x.LineNumber, - OriginType = (SalesDocumentOriginType)x.OriginType, - OriginId = x.OriginId, - QuoteDetailId = x.QuoteDetailId, - Description = x.Description.Trim(), - Quantity = x.Quantity, - UnitPrice = x.UnitPrice, - NetAmount = netAmount, - TaxAmount = x.TaxAmount, - TotalAmount = totalAmount - }; - }).ToList(), - Coverage = new List - { - new() - { - QuoteId = Model.QuoteId!.Value, - CoverageType = Model.CoverageType, - CoveragePercentage = Model.CoveragePercentage, - CoverageAmount = Items.Sum(GetItemTotal), - PeriodFrom = Model.PeriodFrom, - PeriodTo = Model.PeriodTo, - Notes = "Coverage manual desde UI" - } - } - }; + DeliveryNoteIds = SelectedIds.ToList(), + DocumentType = (int)SalesDocumentType.Invoice, + IssueDate = DateTime.Today, + Currency = "ARS", + ExchangeRate = 1, + Observations = Observations + }); - var created = await SalesDocumentService.CreateAsync(request); - toastService.ShowSuccess("Sales Document creado correctamente."); + toastService.ShowSuccess("Sales Document creado correctamente desde remitos."); Navigation.NavigateTo($"/salesdocuments/{created.Id}"); } catch (Exception ex) @@ -396,82 +260,4 @@ } private void BackToList() => Navigation.NavigateTo("/salesdocuments"); - - private static decimal GetItemNet(SalesDocumentItemRow item) => item.Quantity * item.UnitPrice; - private static decimal GetItemTotal(SalesDocumentItemRow item) => GetItemNet(item) + item.TaxAmount; - - private static string GetDocumentTypeLabel(int value) => Enum.IsDefined(typeof(SalesDocumentType), value) - ? ((SalesDocumentType)value) switch - { - SalesDocumentType.Invoice => "Factura", - SalesDocumentType.DebitNote => "Nota de débito", - SalesDocumentType.CreditNote => "Nota de crédito", - SalesDocumentType.CreditInvoice => "Factura crédito", - SalesDocumentType.CreditDebitNote => "N/D crédito", - SalesDocumentType.CreditCreditNote => "N/C crédito", - _ => value.ToString() - } - : value.ToString(); - - private static string GetCoverageTypeLabel(int value) => Enum.IsDefined(typeof(SalesDocumentCoverageType), value) - ? ((SalesDocumentCoverageType)value) switch - { - SalesDocumentCoverageType.Direct => "Directa", - SalesDocumentCoverageType.Capita => "Cápita", - SalesDocumentCoverageType.Adjustment => "Ajuste", - SalesDocumentCoverageType.Manual => "Manual", - _ => value.ToString() - } - : value.ToString(); - - private static string GetOriginTypeLabel(SalesDocumentOriginType value) => value switch - { - SalesDocumentOriginType.Manual => "Manual", - SalesDocumentOriginType.QuoteDetail => "Presupuesto", - SalesDocumentOriginType.Adjustment => "Ajuste", - SalesDocumentOriginType.Capita => "Cápita", - SalesDocumentOriginType.DeliveryNote => "Remito", - _ => value.ToString() - }; - - private sealed class SalesDocumentCreatePageModel - { - [Required(ErrorMessage = "La fecha es obligatoria.")] - public DateTime? IssueDate { get; set; } - - public int DocumentType { get; set; } - - [Required(ErrorMessage = "El cliente es obligatorio.")] - public int? CustomerId { get; set; } - - [Required(ErrorMessage = "El cliente de facturación es obligatorio.")] - public int? BillToCustomerId { get; set; } - - [Required(ErrorMessage = "El presupuesto es obligatorio para coverage.")] - public int? QuoteId { get; set; } - - [Required(ErrorMessage = "La moneda es obligatoria.")] - public string Currency { get; set; } = string.Empty; - - public decimal ExchangeRate { get; set; } - public int CoverageType { get; set; } - public decimal? CoveragePercentage { get; set; } - public DateTime? PeriodFrom { get; set; } - public DateTime? PeriodTo { get; set; } - public string? Observations { get; set; } - } - - private sealed class SalesDocumentItemRow - { - public int LineNumber { get; set; } - public int OriginType { get; set; } - public int? OriginId { get; set; } - public int? QuoteDetailId { get; set; } - public string Description { get; set; } = string.Empty; - public decimal Quantity { get; set; } - public decimal UnitPrice { get; set; } - public decimal TaxAmount { get; set; } - } - - private sealed record SelectOption(int Value, string Label); } diff --git a/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs b/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs index f174fb0..b2d4d0a 100644 --- a/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs +++ b/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs @@ -8,5 +8,15 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments Task> SearchAsync(SalesDocumentSearchParams searchParams); Task GetByIdAsync(int id); Task CreateAsync(SalesDocumentCreateRequest request); + Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request); + Task> SearchDeliveryNoteCandidatesAsync( + int? customerId, + string? customerText, + string? deliveryNoteNumber, + int? quoteId, + DateTime? issueDateFrom, + DateTime? issueDateTo, + int page = 1, + int pageSize = 50); } } diff --git a/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs b/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs index e92469e..8c0b8d3 100644 --- a/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs +++ b/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs @@ -43,6 +43,60 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments return result ?? new PagedResult(); } + + public async Task> SearchDeliveryNoteCandidatesAsync( + int? customerId, + string? customerText, + string? deliveryNoteNumber, + int? quoteId, + DateTime? issueDateFrom, + DateTime? issueDateTo, + int page = 1, + int pageSize = 50) + { + var queryParams = new List(); + + void AddParam(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + queryParams.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + AddParam("customerId", customerId?.ToString()); + AddParam("customerText", customerText); + AddParam("deliveryNoteNumber", deliveryNoteNumber); + AddParam("quoteId", quoteId?.ToString()); + AddParam("issueDateFrom", issueDateFrom?.ToString("o")); + AddParam("issueDateTo", issueDateTo?.ToString("o")); + AddParam("page", page.ToString()); + AddParam("pageSize", pageSize.ToString()); + + var url = "/api/SalesDocument/delivery-note-candidates"; + if (queryParams.Any()) + url += "?" + string.Join("&", queryParams); + + var result = await _http.GetFromJsonAsync>(url); + return result ?? new PagedResult(); + } + + public async Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var response = await _http.PostAsJsonAsync("/api/SalesDocument/from-delivery-notes", request); + + if (!response.IsSuccessStatusCode) + { + var serverMessage = await response.Content.ReadAsStringAsync(); + throw new Exception(string.IsNullOrWhiteSpace(serverMessage) + ? "No se pudo crear el Sales Document desde remitos." + : serverMessage); + } + + var result = await response.Content.ReadFromJsonAsync(); + return result ?? throw new Exception("Respuesta vacía del servidor."); + } + public async Task GetByIdAsync(int id) { return await _http.GetFromJsonAsync($"/api/SalesDocument/{id}");