Compare commits
No commits in common. "a822ae7c872be6d0a51b7d620da1d28240e0b5f1" and "ab4f73f9415e109a887b3a8d99e2282029c6d4cb" have entirely different histories.
a822ae7c87
...
ab4f73f941
@ -18,11 +18,9 @@ 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,208 +57,6 @@ 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);
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
namespace Domain.Dtos.Sales
|
|
||||||
{
|
|
||||||
public class SalesDocumentDeliveryNoteItemSelectionDto
|
|
||||||
{
|
|
||||||
public int DeliveryNoteDetailId { get; set; }
|
|
||||||
public decimal SelectedQuantity { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -19,10 +19,7 @@ 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,4 +1,3 @@
|
|||||||
using Domain.Constants;
|
|
||||||
using Domain.Dtos.Sales;
|
using Domain.Dtos.Sales;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Generics;
|
using Domain.Generics;
|
||||||
@ -237,211 +236,6 @@ 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
|
||||||
|
|||||||
@ -1,521 +0,0 @@
|
|||||||
# 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,61 +175,6 @@ 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="Numero de remito..." />
|
<input class="form-control" @bind="DeliveryNoteNumber" placeholder="Número 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</h5>
|
<h5 class="mb-0">Remitos emitidos pendientes</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">Items</th>
|
<th class="text-end">Ítems</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="async args => await ToggleSelectionAsync(item, args)" />
|
<input type="checkbox" class="form-check-input" checked="@SelectedIds.Contains(item.Id)" @onchange="args => ToggleSelection(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,77 +95,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-center text-muted py-4">Busca remitos emitidos para seleccionar sus items facturables.</div>
|
<div class="text-center text-muted py-4">Buscá remitos emitidos pendientes de facturación.</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>
|
||||||
@ -173,7 +103,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 (SelectedItems.Any())
|
@if (SelectedCandidates.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">
|
||||||
@ -181,20 +111,20 @@
|
|||||||
@SelectedCustomerLabel
|
@SelectedCustomerLabel
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<strong>Remitos:</strong><br />@SelectedDeliveryNoteCount
|
<strong>Remitos:</strong><br />@SelectedCandidates.Count
|
||||||
</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>Validacion:</strong><br />
|
<strong>Validación:</strong><br />
|
||||||
@if (HasSingleFiscalCustomer && HasValidSelectedQuantities)
|
@if (HasSingleFiscalCustomer)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success">Seleccion valida</span>
|
<span class="badge bg-success">Cliente único</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="badge bg-danger">Revisar seleccion</span>
|
<span class="badge bg-danger">Clientes fiscales distintos</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -205,7 +135,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-muted">Selecciona items facturables para ver el resumen.</div>
|
<div class="text-muted">Seleccioná uno o más remitos para ver el resumen.</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -232,28 +162,16 @@
|
|||||||
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 List<SalesDocumentDeliveryNoteItemCandidateDto> SelectedItems => ItemCandidates
|
private bool HasSingleFiscalCustomer => SelectedCandidates.Select(x => x.CustomerId).Distinct().Count() <= 1;
|
||||||
.Where(x => SelectedItemIds.Contains(x.DeliveryNoteDetailId))
|
private bool CanCreate => SelectedIds.Count > 0 && HasSingleFiscalCustomer && SelectedTotal > 0;
|
||||||
.OrderBy(x => x.DeliveryNoteIssueDate)
|
private decimal SelectedTotal => SelectedCandidates.Sum(x => x.ApprovedAmount);
|
||||||
.ThenBy(x => x.DeliveryNoteId)
|
private string SelectedCustomerLabel => SelectedCandidates.FirstOrDefault()?.CustomerName ?? "-";
|
||||||
.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()
|
||||||
{
|
{
|
||||||
@ -269,11 +187,6 @@
|
|||||||
IssueDateTo,
|
IssueDateTo,
|
||||||
1,
|
1,
|
||||||
50);
|
50);
|
||||||
|
|
||||||
SelectedIds.Clear();
|
|
||||||
SelectedMap.Clear();
|
|
||||||
ItemCandidates.Clear();
|
|
||||||
SelectedItemIds.Clear();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -285,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ToggleSelectionAsync(SalesDocumentDeliveryNoteCandidateDto item, ChangeEventArgs args)
|
private void ToggleSelection(SalesDocumentDeliveryNoteCandidateDto item, ChangeEventArgs args)
|
||||||
{
|
{
|
||||||
var selected = args.Value is bool value && value;
|
var selected = args.Value is bool value && value;
|
||||||
if (selected)
|
if (selected)
|
||||||
@ -298,42 +211,6 @@
|
|||||||
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()
|
||||||
@ -346,28 +223,22 @@
|
|||||||
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 items pendientes de un unico cliente fiscal y cantidades validas.");
|
toastService.ShowError("Debe seleccionar remitos pendientes de un único cliente fiscal.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IsSaving = true;
|
IsSaving = true;
|
||||||
var created = await SalesDocumentService.CreateFromDeliveryNoteItemsAsync(new SalesDocumentCreateFromDeliveryNoteItemsRequest
|
var created = await SalesDocumentService.CreateFromDeliveryNotesAsync(new SalesDocumentCreateFromDeliveryNotesRequest
|
||||||
{
|
{
|
||||||
Items = SelectedItems.Select(x => new SalesDocumentDeliveryNoteItemSelectionDto
|
DeliveryNoteIds = SelectedIds.ToList(),
|
||||||
{
|
|
||||||
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",
|
||||||
@ -375,7 +246,7 @@
|
|||||||
Observations = Observations
|
Observations = Observations
|
||||||
});
|
});
|
||||||
|
|
||||||
toastService.ShowSuccess("Sales Document creado correctamente desde items de remitos.");
|
toastService.ShowSuccess("Sales Document creado correctamente desde remitos.");
|
||||||
Navigation.NavigateTo($"/salesdocuments/{created.Id}");
|
Navigation.NavigateTo($"/salesdocuments/{created.Id}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -388,10 +259,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static decimal GetSelectedAmount(SalesDocumentDeliveryNoteItemCandidateDto item)
|
|
||||||
{
|
|
||||||
return item.ApprovedUnitPrice * item.SelectedQuantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
|
private void BackToList() => Navigation.NavigateTo("/salesdocuments");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,6 @@ 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,42 +97,6 @@ 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