feat(sales): support delivery note item selection for partial billing close #76
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 22m41s
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 22m41s
This commit is contained in:
parent
10db654679
commit
1a2478d0c7
@ -18,9 +18,11 @@ namespace Core.Interfaces
|
|||||||
|
|
||||||
Task<SalesDocumentCreateResponse> CreateAsync(SalesDocumentCreateRequest request);
|
Task<SalesDocumentCreateResponse> CreateAsync(SalesDocumentCreateRequest request);
|
||||||
Task<SalesDocumentCreateResponse> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
|
Task<SalesDocumentCreateResponse> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
|
||||||
|
Task<SalesDocumentCreateResponse> CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request);
|
||||||
Task<SalesDocumentDraftPreviewDto?> GetDraftPreviewAsync(int id);
|
Task<SalesDocumentDraftPreviewDto?> GetDraftPreviewAsync(int id);
|
||||||
Task<SalesDocumentDraftPreviewDto?> UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review);
|
Task<SalesDocumentDraftPreviewDto?> UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review);
|
||||||
Task<SalesDocumentDraftPreviewDto?> ValidateDraftAsync(int id);
|
Task<SalesDocumentDraftPreviewDto?> ValidateDraftAsync(int id);
|
||||||
|
Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteIds);
|
||||||
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
|
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
|
||||||
int? customerId,
|
int? customerId,
|
||||||
string? customerText,
|
string? customerText,
|
||||||
|
|||||||
@ -57,6 +57,208 @@ namespace Core.Services
|
|||||||
pageSize);
|
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)
|
public async Task<SalesDocumentCreateResponse> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(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> CreateAsync(ESalesDocument entity);
|
||||||
Task<ESalesDocument> CreateFromDeliveryNotesAsync(ESalesDocument entity, IReadOnlyCollection<int> deliveryNoteIds);
|
Task<ESalesDocument> CreateFromDeliveryNotesAsync(ESalesDocument entity, IReadOnlyCollection<int> deliveryNoteIds);
|
||||||
|
Task<ESalesDocument> CreateFromDeliveryNoteItemsAsync(ESalesDocument entity);
|
||||||
Task<List<DeliveryNoteDto>> GetDeliveryNotesForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteIds);
|
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(
|
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
|
||||||
int? customerId,
|
int? customerId,
|
||||||
string? customerText,
|
string? customerText,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Domain.Constants;
|
||||||
using Domain.Dtos.Sales;
|
using Domain.Dtos.Sales;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Generics;
|
using Domain.Generics;
|
||||||
@ -236,6 +237,211 @@ namespace Models.Repositories
|
|||||||
return EntityMapper.MapEntity<PhSSalesDocument, ESalesDocument>(mapped);
|
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)
|
private static DeliveryNoteDto MapDeliveryNoteDto(PhSDeliveryNote source)
|
||||||
{
|
{
|
||||||
return new DeliveryNoteDto
|
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")]
|
[HttpPost("from-delivery-notes")]
|
||||||
public async Task<ActionResult<SalesDocumentDto>> CreateFromDeliveryNotes([FromBody] SalesDocumentCreateFromDeliveryNotesRequest request)
|
public async Task<ActionResult<SalesDocumentDto>> CreateFromDeliveryNotes([FromBody] SalesDocumentCreateFromDeliveryNotesRequest request)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Remito</label>
|
<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>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Presupuesto ID</label>
|
<label class="form-label">Presupuesto ID</label>
|
||||||
@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<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>
|
<span class="badge bg-secondary">Seleccionados: @SelectedIds.Count</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
@ -70,7 +70,7 @@
|
|||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
<th>Cliente fiscal</th>
|
<th>Cliente fiscal</th>
|
||||||
<th>Presupuesto</th>
|
<th>Presupuesto</th>
|
||||||
<th class="text-end">Ítems</th>
|
<th class="text-end">Items</th>
|
||||||
<th class="text-end">Importe aprobado</th>
|
<th class="text-end">Importe aprobado</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -79,7 +79,7 @@
|
|||||||
{
|
{
|
||||||
<tr class="@(SelectedIds.Contains(item.Id) ? "table-primary" : string.Empty)">
|
<tr class="@(SelectedIds.Contains(item.Id) ? "table-primary" : string.Empty)">
|
||||||
<td class="text-center">
|
<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>
|
||||||
<td>@item.DeliveryNoteNumber</td>
|
<td>@item.DeliveryNoteNumber</td>
|
||||||
<td>@item.IssueDate.ToString("dd/MM/yyyy")</td>
|
<td>@item.IssueDate.ToString("dd/MM/yyyy")</td>
|
||||||
@ -95,7 +95,77 @@
|
|||||||
}
|
}
|
||||||
else
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -103,7 +173,7 @@
|
|||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header"><h5 class="mb-0">Resumen comercial</h5></div>
|
<div class="card-header"><h5 class="mb-0">Resumen comercial</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (SelectedCandidates.Any())
|
@if (SelectedItems.Any())
|
||||||
{
|
{
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@ -111,20 +181,20 @@
|
|||||||
@SelectedCustomerLabel
|
@SelectedCustomerLabel
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<strong>Remitos:</strong><br />@SelectedCandidates.Count
|
<strong>Remitos:</strong><br />@SelectedDeliveryNoteCount
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<strong>Total:</strong><br />@SelectedTotal.ToString("N2")
|
<strong>Total:</strong><br />@SelectedTotal.ToString("N2")
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<strong>Validación:</strong><br />
|
<strong>Validacion:</strong><br />
|
||||||
@if (HasSingleFiscalCustomer)
|
@if (HasSingleFiscalCustomer && HasValidSelectedQuantities)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success">Cliente único</span>
|
<span class="badge bg-success">Seleccion valida</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="badge bg-danger">Clientes fiscales distintos</span>
|
<span class="badge bg-danger">Revisar seleccion</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -135,7 +205,7 @@
|
|||||||
}
|
}
|
||||||
else
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -162,16 +232,28 @@
|
|||||||
private DateTime? IssueDateTo;
|
private DateTime? IssueDateTo;
|
||||||
private string? Observations;
|
private string? Observations;
|
||||||
private bool IsLoading;
|
private bool IsLoading;
|
||||||
|
private bool IsLoadingItems;
|
||||||
private bool IsSaving;
|
private bool IsSaving;
|
||||||
private PagedResult<SalesDocumentDeliveryNoteCandidateDto> Candidates = new();
|
private PagedResult<SalesDocumentDeliveryNoteCandidateDto> Candidates = new();
|
||||||
|
private List<SalesDocumentDeliveryNoteItemCandidateDto> ItemCandidates = new();
|
||||||
private readonly HashSet<int> SelectedIds = new();
|
private readonly HashSet<int> SelectedIds = new();
|
||||||
private readonly Dictionary<int, SalesDocumentDeliveryNoteCandidateDto> SelectedMap = 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 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 List<SalesDocumentDeliveryNoteItemCandidateDto> SelectedItems => ItemCandidates
|
||||||
private bool CanCreate => SelectedIds.Count > 0 && HasSingleFiscalCustomer && SelectedTotal > 0;
|
.Where(x => SelectedItemIds.Contains(x.DeliveryNoteDetailId))
|
||||||
private decimal SelectedTotal => SelectedCandidates.Sum(x => x.ApprovedAmount);
|
.OrderBy(x => x.DeliveryNoteIssueDate)
|
||||||
private string SelectedCustomerLabel => SelectedCandidates.FirstOrDefault()?.CustomerName ?? "-";
|
.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()
|
private async Task SearchCandidatesAsync()
|
||||||
{
|
{
|
||||||
@ -187,6 +269,11 @@
|
|||||||
IssueDateTo,
|
IssueDateTo,
|
||||||
1,
|
1,
|
||||||
50);
|
50);
|
||||||
|
|
||||||
|
SelectedIds.Clear();
|
||||||
|
SelectedMap.Clear();
|
||||||
|
ItemCandidates.Clear();
|
||||||
|
SelectedItemIds.Clear();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
var selected = args.Value is bool value && value;
|
||||||
if (selected)
|
if (selected)
|
||||||
@ -211,6 +298,42 @@
|
|||||||
SelectedIds.Remove(item.Id);
|
SelectedIds.Remove(item.Id);
|
||||||
SelectedMap.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()
|
private void ClearFilters()
|
||||||
@ -223,22 +346,28 @@
|
|||||||
Candidates = new PagedResult<SalesDocumentDeliveryNoteCandidateDto>();
|
Candidates = new PagedResult<SalesDocumentDeliveryNoteCandidateDto>();
|
||||||
SelectedIds.Clear();
|
SelectedIds.Clear();
|
||||||
SelectedMap.Clear();
|
SelectedMap.Clear();
|
||||||
|
ItemCandidates.Clear();
|
||||||
|
SelectedItemIds.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreateAsync()
|
private async Task CreateAsync()
|
||||||
{
|
{
|
||||||
if (!CanCreate)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IsSaving = true;
|
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,
|
DocumentType = (int)SalesDocumentType.Invoice,
|
||||||
IssueDate = DateTime.Today,
|
IssueDate = DateTime.Today,
|
||||||
Currency = "ARS",
|
Currency = "ARS",
|
||||||
@ -246,7 +375,7 @@
|
|||||||
Observations = Observations
|
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}");
|
Navigation.NavigateTo($"/salesdocuments/{created.Id}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -259,5 +388,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static decimal GetSelectedAmount(SalesDocumentDeliveryNoteItemCandidateDto item)
|
||||||
|
{
|
||||||
|
return item.ApprovedUnitPrice * item.SelectedQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
|
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
|
|||||||
Task<SalesDocumentDraftPreviewDto> ValidateDraftAsync(int id);
|
Task<SalesDocumentDraftPreviewDto> ValidateDraftAsync(int id);
|
||||||
Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request);
|
Task<SalesDocumentDto> CreateAsync(SalesDocumentCreateRequest request);
|
||||||
Task<SalesDocumentDto> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
|
Task<SalesDocumentDto> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request);
|
||||||
|
Task<SalesDocumentDto> CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request);
|
||||||
|
Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesAsync(IReadOnlyCollection<int> deliveryNoteIds);
|
||||||
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
|
Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
|
||||||
int? customerId,
|
int? customerId,
|
||||||
string? customerText,
|
string? customerText,
|
||||||
|
|||||||
@ -97,6 +97,42 @@ namespace phronCare.UIBlazor.Services.Sales.SalesDocuments
|
|||||||
return result ?? throw new Exception("Respuesta vacía del servidor.");
|
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)
|
public async Task<SalesDocumentDto?> GetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
return await _http.GetFromJsonAsync<SalesDocumentDto>($"/api/SalesDocument/{id}");
|
return await _http.GetFromJsonAsync<SalesDocumentDto>($"/api/SalesDocument/{id}");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user