diff --git a/Core/Interfaces/ISalesDocumentDom.cs b/Core/Interfaces/ISalesDocumentDom.cs index bba1178..f5b5346 100644 --- a/Core/Interfaces/ISalesDocumentDom.cs +++ b/Core/Interfaces/ISalesDocumentDom.cs @@ -18,9 +18,11 @@ namespace Core.Interfaces Task CreateAsync(SalesDocumentCreateRequest request); Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request); + Task CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request); Task GetDraftPreviewAsync(int id); Task UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review); Task ValidateDraftAsync(int id); + Task> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds); Task> SearchDeliveryNoteCandidatesAsync( int? customerId, string? customerText, diff --git a/Core/Services/SalesDocumentService.cs b/Core/Services/SalesDocumentService.cs index f98ee58..bdf8ba4 100644 --- a/Core/Services/SalesDocumentService.cs +++ b/Core/Services/SalesDocumentService.cs @@ -57,6 +57,208 @@ namespace Core.Services pageSize); } + public Task> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds) + { + ArgumentNullException.ThrowIfNull(deliveryNoteIds); + + var ids = deliveryNoteIds + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0) + throw new InvalidOperationException("Debe seleccionar al menos un remito emitido."); + + return _salesDocumentRepository.GetDeliveryNoteItemCandidatesForSalesDocumentAsync(ids); + } + + public async Task CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.Currency)) + throw new ArgumentException("La moneda es obligatoria.", nameof(request.Currency)); + + var selectedQuantities = request.Items + .Where(x => x.DeliveryNoteDetailId > 0) + .GroupBy(x => x.DeliveryNoteDetailId) + .ToDictionary(x => x.Key, x => x.Sum(i => i.SelectedQuantity)); + + if (selectedQuantities.Count == 0) + throw new InvalidOperationException("Debe seleccionar al menos un item de remito."); + + if (selectedQuantities.Any(x => x.Value <= 0)) + throw new InvalidOperationException("Todas las cantidades seleccionadas deben ser mayores a cero."); + + var candidates = await _salesDocumentRepository.GetDeliveryNoteItemCandidatesByDetailIdsForSalesDocumentAsync(selectedQuantities.Keys.ToList()); + + if (candidates.Count != selectedQuantities.Count) + throw new InvalidOperationException("Uno o mas items seleccionados no existen o no pertenecen a remitos emitidos."); + + foreach (var candidate in candidates) + { + var selectedQuantity = selectedQuantities[candidate.DeliveryNoteDetailId]; + if (candidate.PendingQuantity <= 0) + throw new InvalidOperationException($"El item {candidate.Description} del remito {candidate.DeliveryNoteNumber} no tiene saldo pendiente."); + + if (selectedQuantity > candidate.PendingQuantity) + throw new InvalidOperationException($"La cantidad seleccionada para {candidate.Description} supera el saldo pendiente."); + + if (!candidate.QuoteDetailId.HasValue || candidate.QuoteDetailId.Value <= 0) + throw new InvalidOperationException($"El item {candidate.Description} no tiene detalle de presupuesto asociado."); + + if (candidate.ApprovedUnitPrice <= 0) + throw new InvalidOperationException($"El item {candidate.Description} no tiene precio aprobado."); + } + + var fiscalCustomerIds = candidates.Select(x => x.CustomerId).Distinct().ToList(); + if (fiscalCustomerIds.Count != 1) + throw new InvalidOperationException("No se pueden agrupar items de remitos de distintos clientes fiscales."); + + var now = DateTime.Now; + var details = new List(); + var lineNumber = 1; + + foreach (var candidate in candidates.OrderBy(x => x.DeliveryNoteIssueDate).ThenBy(x => x.DeliveryNoteId).ThenBy(x => x.LineNumber)) + { + var selectedQuantity = selectedQuantities[candidate.DeliveryNoteDetailId]; + var selectedAmount = candidate.ApprovedUnitPrice * selectedQuantity; + var billedPercentage = candidate.DeliveredQuantity <= 0 + ? 0 + : Math.Round(selectedQuantity / candidate.DeliveredQuantity * 100, 2); + + var originSnapshot = JsonSerializer.Serialize(new + { + deliveryNoteId = candidate.DeliveryNoteId, + deliveryNoteNumber = candidate.DeliveryNoteNumber, + deliveryNoteIssueDate = candidate.DeliveryNoteIssueDate, + deliveryNoteDetailId = candidate.DeliveryNoteDetailId, + deliveryNoteLineNumber = candidate.LineNumber, + quoteId = candidate.QuoteId, + quoteNumber = candidate.QuoteNumber, + quoteDetailId = candidate.QuoteDetailId, + customerId = candidate.CustomerId, + customerName = candidate.CustomerName, + originalQuantity = candidate.DeliveredQuantity, + alreadyBilledQuantity = candidate.AlreadyBilledQuantity, + pendingQuantity = candidate.PendingQuantity, + selectedQuantity, + deliveryNoteExtraInfo = candidate.DeliveryNoteExtraInfoJson + }); + + var detail = new ESalesDocumentDetail + { + LineNumber = lineNumber++, + OriginType = SalesDocumentOriginType.DeliveryNote.ToStorageCode(), + OriginId = candidate.DeliveryNoteDetailId, + QuoteDetailId = candidate.QuoteDetailId, + ProductId = candidate.ProductId, + Description = candidate.Description.Trim(), + Quantity = selectedQuantity, + AuthorizedUnitPrice = candidate.ApprovedUnitPrice, + AuthorizedAmount = candidate.ApprovedAmount, + BilledPercentage = billedPercentage, + UnitPrice = candidate.ApprovedUnitPrice, + NetAmount = selectedAmount, + TaxAmount = 0, + TotalAmount = selectedAmount, + OriginSnapshotJson = originSnapshot, + Createdat = now + }; + + if (candidate.QuoteId.HasValue) + { + detail.PhSSalesDocumentCoverages.Add(new ESalesDocumentCoverage + { + QuoteId = candidate.QuoteId.Value, + QuoteDetailId = candidate.QuoteDetailId, + CoverageType = (int)SalesDocumentCoverageType.Direct, + CoveragePercentage = billedPercentage, + CoverageAmount = selectedAmount, + PeriodFrom = request.PeriodFrom, + PeriodTo = request.PeriodTo, + Notes = $"Coverage parcial desde remito {candidate.DeliveryNoteNumber} linea {candidate.LineNumber}", + Createdat = now + }); + } + + details.Add(detail); + } + + var totalAmount = details.Sum(x => x.TotalAmount); + if (totalAmount <= 0) + throw new InvalidOperationException("El total del documento debe ser mayor a cero."); + + var quoteIds = candidates + .Where(x => x.QuoteId.HasValue) + .Select(x => x.QuoteId!.Value) + .Distinct() + .ToList(); + + var operations = candidates + .GroupBy(x => new + { + x.DeliveryNoteId, + x.DeliveryNoteNumber, + x.QuoteId, + x.QuoteNumber, + x.CustomerId, + x.CustomerName, + x.DeliveryNoteIssueDate + }) + .Select(x => new SalesDocumentDeliveryNoteOperationSnapshotDto + { + DeliveryNoteId = x.Key.DeliveryNoteId, + DeliveryNoteNumber = x.Key.DeliveryNoteNumber, + QuoteId = x.Key.QuoteId, + QuoteNumber = x.Key.QuoteNumber, + CustomerId = x.Key.CustomerId, + CustomerName = x.Key.CustomerName, + IssueDate = x.Key.DeliveryNoteIssueDate, + Amount = x.Sum(i => i.ApprovedUnitPrice * selectedQuantities[i.DeliveryNoteDetailId]), + Coverage = x.Key.CustomerName + }) + .ToList(); + + var extraInfoJson = JsonSerializer.Serialize(new + { + source = "DeliveryNoteItems", + grouped = operations.Count > 1, + partialBilling = true, + coverageDefinition = "Entidad financiadora / pagadora responsable", + operations + }); + + var entity = new ESalesDocument + { + DocumentType = request.DocumentType, + Status = (int)SalesDocumentStatus.Draft, + QuoteId = quoteIds.Count == 1 ? quoteIds[0] : 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 + }; + + var created = await _salesDocumentRepository.CreateFromDeliveryNoteItemsAsync(entity); + + return new SalesDocumentCreateResponse + { + Id = created.Id, + InternalDocumentNumber = created.InternalDocumentNumber + }; + } + public async Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request) { ArgumentNullException.ThrowIfNull(request); diff --git a/Database/story-76-sales-document-details-origin-index.sql b/Database/story-76-sales-document-details-origin-index.sql new file mode 100644 index 0000000..059e0ef --- /dev/null +++ b/Database/story-76-sales-document-details-origin-index.sql @@ -0,0 +1,11 @@ +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = 'IX_PhS_SalesDocumentDetails_Origin' + AND object_id = OBJECT_ID('dbo.PhS_SalesDocumentDetails') +) +BEGIN + CREATE INDEX IX_PhS_SalesDocumentDetails_Origin + ON dbo.PhS_SalesDocumentDetails (origin_type, origin_id) + INCLUDE (salesdocument_id, quantity); +END; diff --git a/Domain/Dtos/Sales/SalesDocumentCreateFromDeliveryNoteItemsRequest.cs b/Domain/Dtos/Sales/SalesDocumentCreateFromDeliveryNoteItemsRequest.cs new file mode 100644 index 0000000..218d7bf --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentCreateFromDeliveryNoteItemsRequest.cs @@ -0,0 +1,14 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentCreateFromDeliveryNoteItemsRequest + { + public List Items { 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/SalesDocumentDeliveryNoteItemCandidateDto.cs b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteItemCandidateDto.cs new file mode 100644 index 0000000..5c3880a --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteItemCandidateDto.cs @@ -0,0 +1,26 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDeliveryNoteItemCandidateDto + { + public int DeliveryNoteId { get; set; } + public string DeliveryNoteNumber { get; set; } = string.Empty; + public DateTime DeliveryNoteIssueDate { get; set; } + public int DeliveryNoteDetailId { get; set; } + public int LineNumber { get; set; } + public int? QuoteId { get; set; } + public string? QuoteNumber { get; set; } + public int? QuoteDetailId { get; set; } + public int? ProductId { get; set; } + public int CustomerId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal DeliveredQuantity { get; set; } + public decimal AlreadyBilledQuantity { get; set; } + public decimal PendingQuantity { get; set; } + public decimal SelectedQuantity { get; set; } + public decimal ApprovedUnitPrice { get; set; } + public decimal ApprovedAmount { get; set; } + public decimal SelectedAmount { get; set; } + public string? DeliveryNoteExtraInfoJson { get; set; } + } +} diff --git a/Domain/Dtos/Sales/SalesDocumentDeliveryNoteItemSelectionDto.cs b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteItemSelectionDto.cs new file mode 100644 index 0000000..0f9b9cf --- /dev/null +++ b/Domain/Dtos/Sales/SalesDocumentDeliveryNoteItemSelectionDto.cs @@ -0,0 +1,8 @@ +namespace Domain.Dtos.Sales +{ + public class SalesDocumentDeliveryNoteItemSelectionDto + { + public int DeliveryNoteDetailId { get; set; } + public decimal SelectedQuantity { get; set; } + } +} diff --git a/Models/Interfaces/IPhSSalesDocumentRepository.cs b/Models/Interfaces/IPhSSalesDocumentRepository.cs index fad228f..97de9f8 100644 --- a/Models/Interfaces/IPhSSalesDocumentRepository.cs +++ b/Models/Interfaces/IPhSSalesDocumentRepository.cs @@ -19,7 +19,10 @@ namespace Models.Interfaces Task CreateAsync(ESalesDocument entity); Task CreateFromDeliveryNotesAsync(ESalesDocument entity, IReadOnlyCollection deliveryNoteIds); + Task CreateFromDeliveryNoteItemsAsync(ESalesDocument entity); Task> GetDeliveryNotesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds); + Task> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds); + Task> GetDeliveryNoteItemCandidatesByDetailIdsForSalesDocumentAsync(IReadOnlyCollection deliveryNoteDetailIds); Task> SearchDeliveryNoteCandidatesAsync( int? customerId, string? customerText, diff --git a/Models/Repositories/PhSSalesDocumentRepository.cs b/Models/Repositories/PhSSalesDocumentRepository.cs index c32de4a..1e2ce74 100644 --- a/Models/Repositories/PhSSalesDocumentRepository.cs +++ b/Models/Repositories/PhSSalesDocumentRepository.cs @@ -1,3 +1,4 @@ +using Domain.Constants; using Domain.Dtos.Sales; using Domain.Entities; using Domain.Generics; @@ -236,6 +237,211 @@ namespace Models.Repositories return EntityMapper.MapEntity(mapped); } + public async Task CreateFromDeliveryNoteItemsAsync(ESalesDocument entity) + { + await using var transaction = await _context.Database.BeginTransactionAsync(); + + var mapped = EntityMapper.MapEntity(entity); + var detailCoverages = mapped.PhSSalesDocumentDetails + .SelectMany(detail => detail.PhSSalesDocumentCoverages.Select(coverage => new + { + Detail = detail, + Coverage = coverage + })) + .ToList(); + + foreach (var detail in mapped.PhSSalesDocumentDetails) + detail.PhSSalesDocumentCoverages.Clear(); + + await _context.PhSSalesDocuments.AddAsync(mapped); + await _context.SaveChangesAsync(); + + foreach (var item in detailCoverages) + { + item.Coverage.SalesdocumentId = mapped.Id; + item.Coverage.SalesdocumentdetailId = item.Detail.Id; + await _context.PhSSalesDocumentCoverages.AddAsync(item.Coverage); + } + + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); + + return EntityMapper.MapEntity(mapped); + } + + public async Task> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection deliveryNoteIds) + { + var ids = deliveryNoteIds + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return new List(); + + var deliveryNoteOrigin = SalesDocumentOriginType.DeliveryNote.ToStorageCode(); + var cancelledStatus = (int)SalesDocumentStatus.Cancelled; + + var details = await _context.PhSDeliveryNoteDetails + .Include(x => x.Deliverynote) + .ThenInclude(x => x.Customer) + .Include(x => x.Deliverynote) + .ThenInclude(x => x.Quote) + .Include(x => x.QuoteDetail) + .AsNoTracking() + .Where(x => ids.Contains(x.DeliverynoteId) && x.Deliverynote.Status == "Emitido") + .OrderBy(x => x.Deliverynote.Issuedate) + .ThenBy(x => x.Deliverynote.Id) + .ThenBy(x => x.LineNumber) + .ThenBy(x => x.Id) + .ToListAsync(); + + var detailIds = details.Select(x => x.Id).ToList(); + + var billedByDeliveryNoteDetail = await _context.PhSSalesDocumentDetails + .AsNoTracking() + .Where(x => + x.OriginType == deliveryNoteOrigin && + x.OriginId.HasValue && + detailIds.Contains(x.OriginId.Value) && + x.Salesdocument.Status != cancelledStatus) + .GroupBy(x => x.OriginId!.Value) + .Select(x => new + { + DeliveryNoteDetailId = x.Key, + Quantity = x.Sum(d => d.Quantity) + }) + .ToDictionaryAsync(x => x.DeliveryNoteDetailId, x => x.Quantity); + + return details + .Select(x => + { + var approvedUnitPrice = x.QuoteDetail?.Approvedunitprice ?? x.QuoteDetail?.Unitprice ?? 0; + var approvedAmount = x.QuoteDetail?.Approvedamount ?? (approvedUnitPrice * x.Quantity); + var lineLevelBilledQuantity = billedByDeliveryNoteDetail.TryGetValue(x.Id, out var billedQuantity) + ? billedQuantity + : 0; + var legacyFullBilledQuantity = x.Deliverynote.SalesinvoiceId.HasValue && lineLevelBilledQuantity == 0 + ? x.Quantity + : 0; + var alreadyBilledQuantity = lineLevelBilledQuantity + legacyFullBilledQuantity; + var pendingQuantity = Math.Max(0, x.Quantity - alreadyBilledQuantity); + var selectedAmount = approvedUnitPrice * pendingQuantity; + + return new SalesDocumentDeliveryNoteItemCandidateDto + { + DeliveryNoteId = x.DeliverynoteId, + DeliveryNoteNumber = x.Deliverynote.Deliverynotenumber, + DeliveryNoteIssueDate = x.Deliverynote.Issuedate, + DeliveryNoteDetailId = x.Id, + LineNumber = x.LineNumber, + QuoteId = x.Deliverynote.QuoteId, + QuoteNumber = x.Deliverynote.Quote?.Quotenumber, + QuoteDetailId = x.QuoteDetailId, + ProductId = x.QuoteDetail?.ProductId, + CustomerId = x.Deliverynote.CustomerId, + CustomerName = x.Deliverynote.Customer.Name ?? string.Empty, + Description = x.Description ?? string.Empty, + DeliveredQuantity = x.Quantity, + AlreadyBilledQuantity = alreadyBilledQuantity, + PendingQuantity = pendingQuantity, + SelectedQuantity = pendingQuantity, + ApprovedUnitPrice = approvedUnitPrice, + ApprovedAmount = approvedAmount, + SelectedAmount = selectedAmount, + DeliveryNoteExtraInfoJson = x.Deliverynote.ExtrainfoJson + }; + }) + .Where(x => x.PendingQuantity > 0) + .ToList(); + } + + public async Task> GetDeliveryNoteItemCandidatesByDetailIdsForSalesDocumentAsync(IReadOnlyCollection deliveryNoteDetailIds) + { + var ids = deliveryNoteDetailIds + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return new List(); + + var deliveryNoteOrigin = SalesDocumentOriginType.DeliveryNote.ToStorageCode(); + var cancelledStatus = (int)SalesDocumentStatus.Cancelled; + + var details = await _context.PhSDeliveryNoteDetails + .Include(x => x.Deliverynote) + .ThenInclude(x => x.Customer) + .Include(x => x.Deliverynote) + .ThenInclude(x => x.Quote) + .Include(x => x.QuoteDetail) + .AsNoTracking() + .Where(x => ids.Contains(x.Id) && x.Deliverynote.Status == "Emitido") + .OrderBy(x => x.Deliverynote.Issuedate) + .ThenBy(x => x.Deliverynote.Id) + .ThenBy(x => x.LineNumber) + .ThenBy(x => x.Id) + .ToListAsync(); + + var detailIds = details.Select(x => x.Id).ToList(); + + var billedByDeliveryNoteDetail = await _context.PhSSalesDocumentDetails + .AsNoTracking() + .Where(x => + x.OriginType == deliveryNoteOrigin && + x.OriginId.HasValue && + detailIds.Contains(x.OriginId.Value) && + x.Salesdocument.Status != cancelledStatus) + .GroupBy(x => x.OriginId!.Value) + .Select(x => new + { + DeliveryNoteDetailId = x.Key, + Quantity = x.Sum(d => d.Quantity) + }) + .ToDictionaryAsync(x => x.DeliveryNoteDetailId, x => x.Quantity); + + return details + .Select(x => + { + var approvedUnitPrice = x.QuoteDetail?.Approvedunitprice ?? x.QuoteDetail?.Unitprice ?? 0; + var approvedAmount = x.QuoteDetail?.Approvedamount ?? (approvedUnitPrice * x.Quantity); + var lineLevelBilledQuantity = billedByDeliveryNoteDetail.TryGetValue(x.Id, out var billedQuantity) + ? billedQuantity + : 0; + var legacyFullBilledQuantity = x.Deliverynote.SalesinvoiceId.HasValue && lineLevelBilledQuantity == 0 + ? x.Quantity + : 0; + var alreadyBilledQuantity = lineLevelBilledQuantity + legacyFullBilledQuantity; + var pendingQuantity = Math.Max(0, x.Quantity - alreadyBilledQuantity); + var selectedAmount = approvedUnitPrice * pendingQuantity; + + return new SalesDocumentDeliveryNoteItemCandidateDto + { + DeliveryNoteId = x.DeliverynoteId, + DeliveryNoteNumber = x.Deliverynote.Deliverynotenumber, + DeliveryNoteIssueDate = x.Deliverynote.Issuedate, + DeliveryNoteDetailId = x.Id, + LineNumber = x.LineNumber, + QuoteId = x.Deliverynote.QuoteId, + QuoteNumber = x.Deliverynote.Quote?.Quotenumber, + QuoteDetailId = x.QuoteDetailId, + ProductId = x.QuoteDetail?.ProductId, + CustomerId = x.Deliverynote.CustomerId, + CustomerName = x.Deliverynote.Customer.Name ?? string.Empty, + Description = x.Description ?? string.Empty, + DeliveredQuantity = x.Quantity, + AlreadyBilledQuantity = alreadyBilledQuantity, + PendingQuantity = pendingQuantity, + SelectedQuantity = pendingQuantity, + ApprovedUnitPrice = approvedUnitPrice, + ApprovedAmount = approvedAmount, + SelectedAmount = selectedAmount, + DeliveryNoteExtraInfoJson = x.Deliverynote.ExtrainfoJson + }; + }) + .ToList(); + } + private static DeliveryNoteDto MapDeliveryNoteDto(PhSDeliveryNote source) { return new DeliveryNoteDto diff --git a/docs/stories/story-76-sales-document-item-selection-partial-billing.md b/docs/stories/story-76-sales-document-item-selection-partial-billing.md new file mode 100644 index 0000000..2671b79 --- /dev/null +++ b/docs/stories/story-76-sales-document-item-selection-partial-billing.md @@ -0,0 +1,521 @@ +# Story #76 — Sales Document Item Selection & Partial Billing + +## Objetivo + +Permitir que un **Sales Document** pueda construirse desde una selección parcial de ítems provenientes de uno o más **Delivery Notes**, desacoplando el concepto de: + +```text +Remito completo +``` + +del concepto de: + +```text +Contenido efectivamente facturable +``` + +La story debe permitir seleccionar líneas, excluir ítems completos y modificar cantidades facturables antes de generar el documento comercial. + +--- + +## Contexto funcional + +El módulo **Sales Document** ya cuenta con persistencia, contratos de dominio, DTOs, Core Flow, repositorios, API oficial, UI BackOffice y Draft Review & Validation implementados en stories previas. + +Actualmente el flujo funcional es: + +```text +Presupuesto + ↓ +Remito + ↓ +Sales Document +``` + +Cuando el usuario selecciona uno o más remitos, el sistema copia automáticamente todo el contenido del remito hacia `SalesDocumentDetails`. + +Ese comportamiento es insuficiente para la operatoria real de ortopedias, financiadores y coberturas médicas, donde pueden existir escenarios como: + +```text +- facturación parcial de un remito; +- facturación parcial de cantidades entregadas; +- exclusión de prestaciones rechazadas; +- facturación en etapas; +- saldos pendientes por línea; +- necesidad futura de notas de crédito sobre líneas específicas. +``` + +Por lo tanto, la evolución correcta del modelo es pasar de: + +```text +DeliveryNote completo → SalesDocument +``` + +a: + +```text +DeliveryNoteDetail → SalesDocumentDetail +``` + +sin rediseñar el módulo completo ni romper los contratos existentes que puedan seguir siendo útiles. + +--- + +## Diagnóstico técnico inicial + +Antes de implementar, Codex debe revisar el repositorio actualizado y confirmar las rutas reales de los archivos. No asumir nombres si difieren del código actual. + +El análisis previo detectó que hoy el flujo crea `SalesDocumentDetails` a partir de todos los ítems de los remitos seleccionados. + +Flujo esperado actual aproximado: + +```text +request.DeliveryNoteIds + ↓ +GetDeliveryNotesForSalesDocumentAsync + ↓ +foreach DeliveryNote + foreach DeliveryNoteItem + crear ESalesDocumentDetail + crear ESalesDocumentCoverage si corresponde + ↓ +CreateFromDeliveryNotesAsync + ↓ +marcar DeliveryNote.SalesinvoiceId = SalesDocument.Id +``` + +Restricción detectada: + +```text +PhSDeliveryNote.SalesinvoiceId +``` + +parece operar como marca de remito ya facturado. Ese criterio sirve para facturación total, pero no sirve como criterio principal para facturación parcial, porque no permite distinguir saldos por línea. + +La solución de esta story debe evitar que `SalesinvoiceId` siga siendo el único mecanismo de control de elegibilidad para facturar. + +--- + +## Alcance + +Implementar soporte comercial para selección parcial de ítems de remitos al crear un Sales Document. + +La implementación debe respetar estrictamente la arquitectura: + +```text +Data → Domain → Core → API → UI +``` + +### 1. Data / Models + +- No modificar manualmente modelos EF generados por scaffold. +- Revisar si las tablas existentes alcanzan para soportar la trazabilidad por línea. +- Preferir no crear tablas nuevas salvo necesidad técnica comprobada. +- Evaluar si conviene agregar índices SQL sobre `PhS_SalesDocumentDetails` para mejorar consultas por origen. +- Si se requiere SQL, entregarlo separado en archivo `.sql`. +- Si se actualiza scaffold, hacerlo mediante el procedimiento habitual del proyecto, no editando modelos EF a mano. + +### 2. Domain + +Agregar o ajustar contratos de dominio para representar candidatos facturables por línea. + +DTOs sugeridos, ajustando nombres a convenciones reales del repo: + +```text +SalesDocumentDeliveryNoteItemCandidateDto +SalesDocumentCreateFromDeliveryNoteItemsRequest +SalesDocumentCreateFromDeliveryNoteItemSelectionRequest +``` + +Cada candidato debe poder exponer como mínimo: + +```text +DeliveryNoteId +DeliveryNoteNumber +DeliveryNoteDetailId +QuoteId +QuoteDetailId +Item description / product description +DeliveredQuantity +AlreadyBilledQuantity +PendingQuantity +SelectedQuantity +ApprovedUnitPrice +SelectedAmount +Coverage information when available +``` + +### 3. Core + +Agregar flujo de negocio para: + +```text +1. obtener ítems candidatos facturables desde uno o más remitos; +2. calcular cantidad ya facturada por línea; +3. calcular cantidad pendiente; +4. validar cantidades seleccionadas; +5. crear SalesDocumentDetails únicamente con las líneas seleccionadas; +6. recalcular importes comerciales; +7. mantener Coverage consistente con las líneas seleccionadas. +``` + +Métodos sugeridos, ajustando nombres al repo: + +```text +GetDeliveryNoteItemCandidatesForSalesDocumentAsync +CreateFromDeliveryNoteItemsAsync +``` + +Reglas mínimas: + +```text +SelectedQuantity > 0 +SelectedQuantity <= PendingQuantity +PendingQuantity > 0 +No mezclar clientes fiscales incompatibles +No crear SalesDocument sin líneas seleccionadas +No duplicar facturación por encima de la cantidad entregada +Preservar precios aprobados del presupuesto/remito cuando existan +``` + +### 4. Repository + +Agregar consultas para obtener saldos por línea. + +El cálculo conceptual debe ser: + +```text +PendingQuantity = DeliveredQuantity - AlreadyBilledQuantity +``` + +donde: + +```text +AlreadyBilledQuantity = SUM(SalesDocumentDetails.Quantity) +``` + +filtrado por el origen correcto de la línea. + +Decisión recomendada: + +```text +SalesDocumentDetail.OriginType = DELIVERY_NOTE +SalesDocumentDetail.OriginId = DeliveryNoteDetail.Id +``` + +No usar `DeliveryNote.Id` como origen principal del detail para este nuevo flujo, porque impide trazabilidad granular por línea. + +Si existen documentos anulados, cancelados o descartados, sus cantidades no deben descontar saldo pendiente. Codex debe revisar los estados reales disponibles antes de implementar el filtro definitivo. + +### 5. API + +Agregar endpoints nuevos sin romper los endpoints existentes. + +Endpoints sugeridos, ajustando rutas y nombres al patrón actual: + +```text +GET /api/SalesDocument/delivery-note-item-candidates +POST /api/SalesDocument/from-delivery-note-items +``` + +El endpoint de candidatos debe permitir consultar uno o más remitos y devolver líneas facturables con saldo pendiente. + +El endpoint de creación debe recibir una selección explícita de líneas y cantidades. + +### 6. UI BackOffice + +Actualizar la creación de Sales Document para que el usuario pueda: + +```text +1. seleccionar uno o más remitos; +2. visualizar los ítems incluidos en esos remitos; +3. seleccionar o excluir líneas; +4. modificar cantidades facturables; +5. ver cantidad entregada, ya facturada y pendiente; +6. visualizar importes recalculados; +7. generar el Sales Document sólo con las líneas seleccionadas. +``` + +La UI debe preservar el estilo y patrones ya usados en BackOffice. + +--- + +## Fuera de alcance + +Esta story NO debe implementar ni modificar: + +```text +ARCA +AFIP +IVA +Factura A/B/C +Notas de crédito +Notas de débito +CAE +Sales Fiscal Document +Integración fiscal +PDF fiscal +Libro IVA +Reglas fiscales argentinas +``` + +Tampoco debe rediseñar por completo el módulo Sales Document ni eliminar contratos existentes si todavía son usados por UI/API. + +--- + +## Decisiones de diseño + +### 1. Modelo conceptual correcto + +La unidad facturable debe ser la línea del remito, no el remito completo. + +Modelo objetivo: + +```text +Presupuesto + ↓ +Remito + ↓ +DeliveryNoteDetail seleccionado + ↓ +SalesDocumentDetail + ↓ +SalesDocumentCoverage + ↓ +SalesFiscalDocument futuro +``` + +### 2. Trazabilidad + +Para el nuevo flujo, `SalesDocumentDetail` debe poder trazar el origen granular: + +```text +OriginType = DELIVERY_NOTE +OriginId = DeliveryNoteDetail.Id +``` + +El snapshot puede incluir información adicional para auditoría: + +```json +{ + "deliveryNoteId": 123, + "deliveryNoteDetailId": 456, + "deliveryNoteNumber": "...", + "originalQuantity": 10, + "selectedQuantity": 4 +} +``` + +### 3. Control de doble facturación + +No debe depender exclusivamente de `PhSDeliveryNote.SalesinvoiceId`. + +La elegibilidad debe calcularse por línea mediante `SalesDocumentDetails` ya existentes y sus cantidades acumuladas. + +### 4. Coverage + +Coverage debe seguir siendo consistente con lo efectivamente facturado. + +Para facturación parcial, el coverage debe reflejar la línea seleccionada y el importe seleccionado, no el total completo del remito. + +Conceptualmente: + +```text +CoverageAmount = importe de la línea seleccionada +CoveragePercentage = SelectedQuantity / DeliveredQuantity * 100 +``` + +Ajustar el cálculo según los campos y reglas reales existentes en el repo. + +### 5. Drafts y reserva de saldo + +Decisión recomendada: + +```text +Los Drafts deben reservar saldo. +``` + +Motivo: evita que dos documentos comerciales en draft consuman la misma línea pendiente. + +Si el repo tiene estados anulados/cancelados, esos documentos no deben reservar saldo. + +--- + +## Preguntas de negocio a validar si el código no permite inferirlas + +Codex debe avanzar con la opción conservadora si no hay evidencia en el repo, pero dejar documentada la decisión. + +1. ¿Un Sales Document en Draft debe reservar saldo? + - Recomendación: sí. + +2. ¿Se permite facturar parcialmente una línea más de una vez hasta completar el total entregado? + - Recomendación: sí. + +3. ¿Se permite facturar una línea sin `QuoteDetailId`? + - Recomendación: no, salvo que el flujo actual ya lo permita explícitamente. + +4. ¿`SalesinvoiceId` debe mantenerse sólo como dato legacy/compatibilidad? + - Recomendación: sí, no usarlo como criterio principal para facturación parcial. + +--- + +## Criterios de aceptación + +✔ El código compila sin errores. + +✔ No se modifican manualmente modelos EF generados por scaffold. + +✔ La arquitectura Data → Domain → Core → API → UI se respeta. + +✔ El usuario puede consultar candidatos facturables por ítem desde uno o más remitos. + +✔ La respuesta de candidatos muestra cantidad entregada, cantidad ya facturada y cantidad pendiente. + +✔ El usuario puede seleccionar líneas completas o parciales para crear el Sales Document. + +✔ No se permite crear un Sales Document sin líneas seleccionadas. + +✔ No se permite facturar una cantidad mayor al saldo pendiente de una línea. + +✔ No se permite duplicar facturación sobre una misma línea por encima de la cantidad entregada. + +✔ `SalesDocumentDetails` se crean únicamente para los ítems seleccionados. + +✔ `SalesDocumentCoverage` se genera de forma consistente con las líneas efectivamente facturadas. + +✔ El flujo existente de Draft Review & Validation continúa funcionando. + +✔ La UI BackOffice permite seleccionar/excluir ítems y modificar cantidades facturables. + +✔ Los endpoints actuales no se rompen. + +✔ Swagger/API queda usable para el nuevo flujo. + +✔ Se incluyen pruebas manuales básicas documentadas. + +--- + +## Pruebas manuales sugeridas + +### Caso 1 — Facturación total equivalente al flujo anterior + +```text +1. Seleccionar un remito con 3 ítems. +2. Seleccionar los 3 ítems completos. +3. Crear Sales Document. +4. Verificar que los detalles coincidan con el total del remito. +``` + +### Caso 2 — Exclusión de línea completa + +```text +1. Seleccionar un remito con 3 ítems. +2. Seleccionar sólo 2 ítems. +3. Crear Sales Document. +4. Verificar que el tercer ítem no aparece en SalesDocumentDetails. +``` + +### Caso 3 — Cantidad parcial + +```text +1. Seleccionar una línea con DeliveredQuantity = 10. +2. Facturar SelectedQuantity = 4. +3. Crear Sales Document. +4. Volver a consultar candidatos. +5. Verificar PendingQuantity = 6. +``` + +### Caso 4 — Evitar doble facturación + +```text +1. Facturar parcialmente una línea. +2. Intentar facturar una cantidad mayor al pendiente. +3. Verificar que el Core/API rechaza la operación. +``` + +### Caso 5 — Múltiples remitos + +```text +1. Seleccionar dos remitos del mismo cliente fiscal. +2. Seleccionar líneas de ambos. +3. Crear Sales Document. +4. Verificar trazabilidad por DeliveryNoteDetail. +``` + +### Caso 6 — Clientes incompatibles + +```text +1. Intentar seleccionar remitos de clientes fiscales incompatibles. +2. Verificar que el sistema rechaza la creación. +``` + +--- + +## Entregable esperado + +Codex debe entregar: + +```text +1. Cambios de código por capas. +2. SQL separado si se requieren índices o estructura adicional. +3. Patch revisado. +4. Checklist de pruebas manuales ejecutadas. +5. Confirmación explícita de que no se modificaron modelos EF scaffold manualmente. +``` + +Archivos/rutas a revisar y posiblemente modificar, ajustando según el repo real: + +```text +Domain/Entities/Sales/* +Domain/DTOs/Sales/* +Core/Interfaces/Sales/* +Core/Services/Sales/SalesDocumentService.cs +Data/Interfaces/Sales/* +Data/Repositories/Sales/* +API/Controllers/SalesDocumentController.cs +UI/BackOffice/Pages/SalesDocuments/* +UI/BackOffice/Services/* +``` + +--- + +## Branch sugerido + +```text +feature/leandro/76-sales-document-item-selection-partial-billing +``` + +--- + +## Commit sugerido + +```text +feat(sales-documents): support delivery note item selection for partial billing close #76 +``` + +--- + +## Instrucciones específicas para Codex + +Antes de escribir código: + +```text +1. Analizar el repositorio completo. +2. Identificar rutas reales y patrones existentes. +3. Confirmar cómo se crean hoy SalesDocumentDetails. +4. Confirmar cómo se relacionan con Coverage. +5. Confirmar cómo se usa SalesinvoiceId. +6. Proponer el plan de cambios por capas. +7. Luego implementar en pasos pequeños. +``` + +Reglas obligatorias: + +```text +- No modificar modelos EF generados por scaffold. +- No romper contratos existentes si todavía son consumidos. +- No rediseñar todo el módulo. +- Mantener separación Ph* / E*. +- Respetar nombres, convenciones y patrones existentes. +- Mantener el cambio acotado a facturación parcial comercial. +``` diff --git a/phronCare.API/Controllers/Sales/SalesDocumentController.cs b/phronCare.API/Controllers/Sales/SalesDocumentController.cs index 9bc2f61..e7a09ac 100644 --- a/phronCare.API/Controllers/Sales/SalesDocumentController.cs +++ b/phronCare.API/Controllers/Sales/SalesDocumentController.cs @@ -175,6 +175,61 @@ namespace phronCare.API.Controllers.Sales } } + [HttpGet("delivery-note-item-candidates")] + public async Task>> GetDeliveryNoteItemCandidates( + [FromQuery] List deliveryNoteIds) + { + try + { + var result = await _salesDocumentService.GetDeliveryNoteItemCandidatesForSalesDocumentAsync(deliveryNoteIds); + return Ok(result); + } + 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("from-delivery-note-items")] + public async Task> CreateFromDeliveryNoteItems([FromBody] SalesDocumentCreateFromDeliveryNoteItemsRequest request) + { + try + { + if (request == null) + return BadRequest("El payload no puede ser nulo."); + + var created = await _salesDocumentService.CreateFromDeliveryNoteItemsAsync(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("from-delivery-notes")] public async Task> CreateFromDeliveryNotes([FromBody] SalesDocumentCreateFromDeliveryNotesRequest request) { diff --git a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor index 49e7c1f..78b9255 100644 --- a/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor +++ b/phronCare.UIBlazor/Pages/Sales/SalesDocuments/SalesDocumentCreate.razor @@ -24,7 +24,7 @@
- +
@@ -55,7 +55,7 @@
-
Remitos emitidos pendientes
+
Remitos emitidos
Seleccionados: @SelectedIds.Count
@@ -70,7 +70,7 @@ Fecha Cliente fiscal Presupuesto - Ítems + Items Importe aprobado @@ -79,7 +79,7 @@ { - + @item.DeliveryNoteNumber @item.IssueDate.ToString("dd/MM/yyyy") @@ -95,7 +95,77 @@ } else { -
Buscá remitos emitidos pendientes de facturación.
+
Busca remitos emitidos para seleccionar sus items facturables.
+ } +
+
+ +
+
+
Items facturables
+ Lineas: @SelectedItemIds.Count +
+
+ @if (IsLoadingItems) + { +
+ + Cargando items... +
+ } + else if (ItemCandidates.Any()) + { +
+ + + + + + + + + + + + + + + + + @foreach (var item in ItemCandidates) + { + var isSelected = SelectedItemIds.Contains(item.DeliveryNoteDetailId); + + + + + + + + + + + + + } + +
RemitoLineaDescripcionEntregadoFacturadoPendienteA facturarPrecioImporte
+ + @item.DeliveryNoteNumber@item.LineNumber@item.Description@item.DeliveredQuantity.ToString("N2")@item.AlreadyBilledQuantity.ToString("N2")@item.PendingQuantity.ToString("N2") + + @item.ApprovedUnitPrice.ToString("N2")@GetSelectedAmount(item).ToString("N2")
+
+ } + else + { +
Selecciona uno o mas remitos para ver sus items con saldo pendiente.
}
@@ -103,7 +173,7 @@
Resumen comercial
- @if (SelectedCandidates.Any()) + @if (SelectedItems.Any()) {
@@ -111,20 +181,20 @@ @SelectedCustomerLabel
- Remitos:
@SelectedCandidates.Count + Remitos:
@SelectedDeliveryNoteCount
Total:
@SelectedTotal.ToString("N2")
- Validación:
- @if (HasSingleFiscalCustomer) + Validacion:
+ @if (HasSingleFiscalCustomer && HasValidSelectedQuantities) { - Cliente único + Seleccion valida } else { - Clientes fiscales distintos + Revisar seleccion }
@@ -135,7 +205,7 @@ } else { -
Seleccioná uno o más remitos para ver el resumen.
+
Selecciona items facturables para ver el resumen.
}
@@ -162,16 +232,28 @@ private DateTime? IssueDateTo; private string? Observations; private bool IsLoading; + private bool IsLoadingItems; private bool IsSaving; private PagedResult Candidates = new(); + private List ItemCandidates = new(); private readonly HashSet SelectedIds = new(); private readonly Dictionary SelectedMap = new(); + private readonly HashSet SelectedItemIds = new(); 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 List SelectedItems => ItemCandidates + .Where(x => SelectedItemIds.Contains(x.DeliveryNoteDetailId)) + .OrderBy(x => x.DeliveryNoteIssueDate) + .ThenBy(x => x.DeliveryNoteId) + .ThenBy(x => x.LineNumber) + .ToList(); + + private bool HasSingleFiscalCustomer => SelectedItems.Select(x => x.CustomerId).Distinct().Count() <= 1; + private bool HasValidSelectedQuantities => SelectedItems.All(x => x.SelectedQuantity > 0 && x.SelectedQuantity <= x.PendingQuantity); + private bool CanCreate => SelectedItems.Any() && HasSingleFiscalCustomer && HasValidSelectedQuantities && SelectedTotal > 0; + private decimal SelectedTotal => SelectedItems.Sum(GetSelectedAmount); + private int SelectedDeliveryNoteCount => SelectedItems.Select(x => x.DeliveryNoteId).Distinct().Count(); + private string SelectedCustomerLabel => SelectedItems.FirstOrDefault()?.CustomerName ?? "-"; private async Task SearchCandidatesAsync() { @@ -187,6 +269,11 @@ IssueDateTo, 1, 50); + + SelectedIds.Clear(); + SelectedMap.Clear(); + ItemCandidates.Clear(); + SelectedItemIds.Clear(); } catch (Exception ex) { @@ -198,7 +285,7 @@ } } - private void ToggleSelection(SalesDocumentDeliveryNoteCandidateDto item, ChangeEventArgs args) + private async Task ToggleSelectionAsync(SalesDocumentDeliveryNoteCandidateDto item, ChangeEventArgs args) { var selected = args.Value is bool value && value; if (selected) @@ -211,6 +298,42 @@ SelectedIds.Remove(item.Id); SelectedMap.Remove(item.Id); } + + await LoadItemCandidatesAsync(); + } + + private async Task LoadItemCandidatesAsync() + { + try + { + IsLoadingItems = true; + ItemCandidates.Clear(); + SelectedItemIds.Clear(); + + if (SelectedIds.Count == 0) + return; + + ItemCandidates = await SalesDocumentService.GetDeliveryNoteItemCandidatesAsync(SelectedIds); + foreach (var item in ItemCandidates) + SelectedItemIds.Add(item.DeliveryNoteDetailId); + } + catch (Exception ex) + { + toastService.ShowError(ex.Message); + } + finally + { + IsLoadingItems = false; + } + } + + private void ToggleItemSelection(SalesDocumentDeliveryNoteItemCandidateDto item, ChangeEventArgs args) + { + var selected = args.Value is bool value && value; + if (selected) + SelectedItemIds.Add(item.DeliveryNoteDetailId); + else + SelectedItemIds.Remove(item.DeliveryNoteDetailId); } private void ClearFilters() @@ -223,22 +346,28 @@ Candidates = new PagedResult(); SelectedIds.Clear(); SelectedMap.Clear(); + ItemCandidates.Clear(); + SelectedItemIds.Clear(); } private async Task CreateAsync() { if (!CanCreate) { - toastService.ShowError("Debe seleccionar remitos pendientes de un único cliente fiscal."); + toastService.ShowError("Debe seleccionar items pendientes de un unico cliente fiscal y cantidades validas."); return; } try { IsSaving = true; - var created = await SalesDocumentService.CreateFromDeliveryNotesAsync(new SalesDocumentCreateFromDeliveryNotesRequest + var created = await SalesDocumentService.CreateFromDeliveryNoteItemsAsync(new SalesDocumentCreateFromDeliveryNoteItemsRequest { - DeliveryNoteIds = SelectedIds.ToList(), + Items = SelectedItems.Select(x => new SalesDocumentDeliveryNoteItemSelectionDto + { + DeliveryNoteDetailId = x.DeliveryNoteDetailId, + SelectedQuantity = x.SelectedQuantity + }).ToList(), DocumentType = (int)SalesDocumentType.Invoice, IssueDate = DateTime.Today, Currency = "ARS", @@ -246,7 +375,7 @@ Observations = Observations }); - toastService.ShowSuccess("Sales Document creado correctamente desde remitos."); + toastService.ShowSuccess("Sales Document creado correctamente desde items de remitos."); Navigation.NavigateTo($"/salesdocuments/{created.Id}"); } catch (Exception ex) @@ -259,5 +388,10 @@ } } + private static decimal GetSelectedAmount(SalesDocumentDeliveryNoteItemCandidateDto item) + { + return item.ApprovedUnitPrice * item.SelectedQuantity; + } + private void BackToList() => Navigation.NavigateTo("/salesdocuments"); } diff --git a/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs b/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs index dce6649..d93648b 100644 --- a/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs +++ b/phronCare.UIBlazor/Services/Sales/SalesDocuments/ISalesDocumentService.cs @@ -12,6 +12,8 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments Task ValidateDraftAsync(int id); Task CreateAsync(SalesDocumentCreateRequest request); Task CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request); + Task CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request); + Task> GetDeliveryNoteItemCandidatesAsync(IReadOnlyCollection deliveryNoteIds); Task> SearchDeliveryNoteCandidatesAsync( int? customerId, string? customerText, diff --git a/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs b/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs index 2eda360..48b2926 100644 --- a/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs +++ b/phronCare.UIBlazor/Services/Sales/SalesDocuments/SalesDocumentService.cs @@ -97,6 +97,42 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments return result ?? throw new Exception("Respuesta vacía del servidor."); } + public async Task> GetDeliveryNoteItemCandidatesAsync(IReadOnlyCollection deliveryNoteIds) + { + ArgumentNullException.ThrowIfNull(deliveryNoteIds); + + var queryParams = deliveryNoteIds + .Where(x => x > 0) + .Distinct() + .Select(x => $"deliveryNoteIds={Uri.EscapeDataString(x.ToString())}") + .ToList(); + + var url = "/api/SalesDocument/delivery-note-item-candidates"; + if (queryParams.Any()) + url += "?" + string.Join("&", queryParams); + + var result = await _http.GetFromJsonAsync>(url); + return result ?? new List(); + } + + public async Task CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var response = await _http.PostAsJsonAsync("/api/SalesDocument/from-delivery-note-items", request); + + if (!response.IsSuccessStatusCode) + { + var serverMessage = await response.Content.ReadAsStringAsync(); + throw new Exception(string.IsNullOrWhiteSpace(serverMessage) + ? "No se pudo crear el Sales Document desde items de remitos." + : serverMessage); + } + + var result = await response.Content.ReadFromJsonAsync(); + return result ?? throw new Exception("Respuesta vacia del servidor."); + } + public async Task GetByIdAsync(int id) { return await _http.GetFromJsonAsync($"/api/SalesDocument/{id}");