Merge pull request 'feat(sales): support delivery note item selection for partial billing close #76' (#77) from feature/leandro/76-sales-document-item-selection-partial-billing into master
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 2m21s

Reviewed-on: #77
This commit is contained in:
Leandro Hernan Rojas 2026-06-12 04:00:52 +00:00
commit a822ae7c87
13 changed files with 1241 additions and 21 deletions

View File

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

View File

@ -57,6 +57,208 @@ namespace Core.Services
pageSize);
}
public Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection<int> 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<SalesDocumentCreateResponse> 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<ESalesDocumentDetail>();
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<SalesDocumentCreateResponse> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request)
{
ArgumentNullException.ThrowIfNull(request);

View File

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

View File

@ -0,0 +1,14 @@
namespace Domain.Dtos.Sales
{
public class SalesDocumentCreateFromDeliveryNoteItemsRequest
{
public List<SalesDocumentDeliveryNoteItemSelectionDto> 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; }
}
}

View File

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

View File

@ -0,0 +1,8 @@
namespace Domain.Dtos.Sales
{
public class SalesDocumentDeliveryNoteItemSelectionDto
{
public int DeliveryNoteDetailId { get; set; }
public decimal SelectedQuantity { get; set; }
}
}

View File

@ -19,7 +19,10 @@ namespace Models.Interfaces
Task<ESalesDocument> CreateAsync(ESalesDocument entity);
Task<ESalesDocument> CreateFromDeliveryNotesAsync(ESalesDocument entity, IReadOnlyCollection<int> deliveryNoteIds);
Task<ESalesDocument> CreateFromDeliveryNoteItemsAsync(ESalesDocument entity);
Task<List<DeliveryNoteDto>> GetDeliveryNotesForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteIds);
Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteIds);
Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesByDetailIdsForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteDetailIds);
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
int? customerId,
string? customerText,

View File

@ -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<PhSSalesDocument, ESalesDocument>(mapped);
}
public async Task<ESalesDocument> CreateFromDeliveryNoteItemsAsync(ESalesDocument entity)
{
await using var transaction = await _context.Database.BeginTransactionAsync();
var mapped = EntityMapper.MapEntity<ESalesDocument, PhSSalesDocument>(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<PhSSalesDocument, ESalesDocument>(mapped);
}
public async Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteIds)
{
var ids = deliveryNoteIds
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0)
return new List<SalesDocumentDeliveryNoteItemCandidateDto>();
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<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesByDetailIdsForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteDetailIds)
{
var ids = deliveryNoteDetailIds
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0)
return new List<SalesDocumentDeliveryNoteItemCandidateDto>();
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

View File

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

View File

@ -175,6 +175,61 @@ namespace phronCare.API.Controllers.Sales
}
}
[HttpGet("delivery-note-item-candidates")]
public async Task<ActionResult<List<SalesDocumentDeliveryNoteItemCandidateDto>>> GetDeliveryNoteItemCandidates(
[FromQuery] List<int> 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<ActionResult<SalesDocumentDto>> 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<ActionResult<SalesDocumentDto>> CreateFromDeliveryNotes([FromBody] SalesDocumentCreateFromDeliveryNotesRequest request)
{

View File

@ -24,7 +24,7 @@
</div>
<div class="col-md-3">
<label class="form-label">Remito</label>
<input class="form-control" @bind="DeliveryNoteNumber" placeholder="Número de remito..." />
<input class="form-control" @bind="DeliveryNoteNumber" placeholder="Numero de remito..." />
</div>
<div class="col-md-2">
<label class="form-label">Presupuesto ID</label>
@ -55,7 +55,7 @@
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Remitos emitidos pendientes</h5>
<h5 class="mb-0">Remitos emitidos</h5>
<span class="badge bg-secondary">Seleccionados: @SelectedIds.Count</span>
</div>
<div class="card-body p-2">
@ -70,7 +70,7 @@
<th>Fecha</th>
<th>Cliente fiscal</th>
<th>Presupuesto</th>
<th class="text-end">Ítems</th>
<th class="text-end">Items</th>
<th class="text-end">Importe aprobado</th>
</tr>
</thead>
@ -79,7 +79,7 @@
{
<tr class="@(SelectedIds.Contains(item.Id) ? "table-primary" : string.Empty)">
<td class="text-center">
<input type="checkbox" class="form-check-input" checked="@SelectedIds.Contains(item.Id)" @onchange="args => ToggleSelection(item, args)" />
<input type="checkbox" class="form-check-input" checked="@SelectedIds.Contains(item.Id)" @onchange="async args => await ToggleSelectionAsync(item, args)" />
</td>
<td>@item.DeliveryNoteNumber</td>
<td>@item.IssueDate.ToString("dd/MM/yyyy")</td>
@ -95,7 +95,77 @@
}
else
{
<div class="text-center text-muted py-4">Buscá remitos emitidos pendientes de facturación.</div>
<div class="text-center text-muted py-4">Busca remitos emitidos para seleccionar sus items facturables.</div>
}
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Items facturables</h5>
<span class="badge bg-secondary">Lineas: @SelectedItemIds.Count</span>
</div>
<div class="card-body p-2">
@if (IsLoadingItems)
{
<div class="text-center text-muted py-4">
<span class="spinner-border spinner-border-sm me-2"></span>
Cargando items...
</div>
}
else if (ItemCandidates.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:48px;"></th>
<th>Remito</th>
<th>Linea</th>
<th>Descripcion</th>
<th class="text-end">Entregado</th>
<th class="text-end">Facturado</th>
<th class="text-end">Pendiente</th>
<th class="text-end" style="width:150px;">A facturar</th>
<th class="text-end">Precio</th>
<th class="text-end">Importe</th>
</tr>
</thead>
<tbody>
@foreach (var item in ItemCandidates)
{
var isSelected = SelectedItemIds.Contains(item.DeliveryNoteDetailId);
<tr class="@(isSelected ? "table-primary" : string.Empty)">
<td class="text-center">
<input type="checkbox" class="form-check-input" checked="@isSelected" @onchange="args => ToggleItemSelection(item, args)" />
</td>
<td>@item.DeliveryNoteNumber</td>
<td>@item.LineNumber</td>
<td>@item.Description</td>
<td class="text-end">@item.DeliveredQuantity.ToString("N2")</td>
<td class="text-end">@item.AlreadyBilledQuantity.ToString("N2")</td>
<td class="text-end">@item.PendingQuantity.ToString("N2")</td>
<td>
<input type="number"
class="form-control form-control-sm text-end"
min="0"
step="0.01"
max="@item.PendingQuantity"
disabled="@(!isSelected)"
@bind="item.SelectedQuantity"
@bind:event="oninput" />
</td>
<td class="text-end">@item.ApprovedUnitPrice.ToString("N2")</td>
<td class="text-end">@GetSelectedAmount(item).ToString("N2")</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center text-muted py-4">Selecciona uno o mas remitos para ver sus items con saldo pendiente.</div>
}
</div>
</div>
@ -103,7 +173,7 @@
<div class="card shadow-sm mb-3">
<div class="card-header"><h5 class="mb-0">Resumen comercial</h5></div>
<div class="card-body">
@if (SelectedCandidates.Any())
@if (SelectedItems.Any())
{
<div class="row g-3 mb-3">
<div class="col-md-4">
@ -111,20 +181,20 @@
@SelectedCustomerLabel
</div>
<div class="col-md-2">
<strong>Remitos:</strong><br />@SelectedCandidates.Count
<strong>Remitos:</strong><br />@SelectedDeliveryNoteCount
</div>
<div class="col-md-3">
<strong>Total:</strong><br />@SelectedTotal.ToString("N2")
</div>
<div class="col-md-3">
<strong>Validación:</strong><br />
@if (HasSingleFiscalCustomer)
<strong>Validacion:</strong><br />
@if (HasSingleFiscalCustomer && HasValidSelectedQuantities)
{
<span class="badge bg-success">Cliente único</span>
<span class="badge bg-success">Seleccion valida</span>
}
else
{
<span class="badge bg-danger">Clientes fiscales distintos</span>
<span class="badge bg-danger">Revisar seleccion</span>
}
</div>
</div>
@ -135,7 +205,7 @@
}
else
{
<div class="text-muted">Seleccioná uno o más remitos para ver el resumen.</div>
<div class="text-muted">Selecciona items facturables para ver el resumen.</div>
}
</div>
</div>
@ -162,16 +232,28 @@
private DateTime? IssueDateTo;
private string? Observations;
private bool IsLoading;
private bool IsLoadingItems;
private bool IsSaving;
private PagedResult<SalesDocumentDeliveryNoteCandidateDto> Candidates = new();
private List<SalesDocumentDeliveryNoteItemCandidateDto> ItemCandidates = new();
private readonly HashSet<int> SelectedIds = new();
private readonly Dictionary<int, SalesDocumentDeliveryNoteCandidateDto> SelectedMap = new();
private readonly HashSet<int> SelectedItemIds = new();
private List<SalesDocumentDeliveryNoteCandidateDto> 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<SalesDocumentDeliveryNoteItemCandidateDto> 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<SalesDocumentDeliveryNoteCandidateDto>();
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");
}

View File

@ -12,6 +12,8 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
Task<SalesDocumentDraftPreviewDto> ValidateDraftAsync(int id);
Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request);
Task<SalesDocumentDto> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
Task<SalesDocumentDto> CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request);
Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesAsync(IReadOnlyCollection<int> deliveryNoteIds);
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
int? customerId,
string? customerText,

View File

@ -97,6 +97,42 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
return result ?? throw new Exception("Respuesta vacía del servidor.");
}
public async Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesAsync(IReadOnlyCollection<int> 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<List<SalesDocumentDeliveryNoteItemCandidateDto>>(url);
return result ?? new List<SalesDocumentDeliveryNoteItemCandidateDto>();
}
public async Task<SalesDocumentDto> 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<SalesDocumentDto>();
return result ?? throw new Exception("Respuesta vacia del servidor.");
}
public async Task<SalesDocumentDto?> GetByIdAsync(int id)
{
return await _http.GetFromJsonAsync<SalesDocumentDto>($"/api/SalesDocument/{id}");