feat(sales): support delivery note item selection for partial billing close #76 #77
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
11
Database/story-76-sales-document-details-origin-index.sql
Normal file
11
Database/story-76-sales-document-details-origin-index.sql
Normal 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;
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
namespace Domain.Dtos.Sales
|
||||
{
|
||||
public class SalesDocumentDeliveryNoteItemSelectionDto
|
||||
{
|
||||
public int DeliveryNoteDetailId { get; set; }
|
||||
public decimal SelectedQuantity { get; set; }
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
```
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user