phronCare/Core/Services/SalesDocumentService.cs
leandro 1a2478d0c7
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 22m41s
feat(sales): support delivery note item selection for partial billing close #76
2026-06-12 00:54:43 -03:00

736 lines
32 KiB
C#

using Core.Interfaces;
using Domain.Constants;
using Domain.Dtos.Sales;
using Domain.Entities;
using Domain.Generics;
using Models.Interfaces;
using System.Text.Json;
namespace Core.Services
{
public class SalesDocumentService(IPhSSalesDocumentRepository salesDocumentRepository) : ISalesDocumentDom
{
private readonly IPhSSalesDocumentRepository _salesDocumentRepository = salesDocumentRepository;
public Task<PagedResult<SalesDocumentSummaryDto>> SearchAsync(
int? customerId,
string? customerText,
int? quoteId,
int? documentType,
int? status,
DateTime? issueDateFrom,
DateTime? issueDateTo,
int page = 1,
int pageSize = 50)
{
return _salesDocumentRepository.SearchAsync(
customerId,
customerText,
quoteId,
documentType,
status,
issueDateFrom,
issueDateTo,
page,
pageSize);
}
public Task<PagedResult<SalesDocumentDeliveryNoteCandidateDto>> SearchDeliveryNoteCandidatesAsync(
int? customerId,
string? customerText,
string? deliveryNoteNumber,
int? quoteId,
DateTime? issueDateFrom,
DateTime? issueDateTo,
int page = 1,
int pageSize = 50)
{
return _salesDocumentRepository.SearchDeliveryNoteCandidatesAsync(
customerId,
customerText,
deliveryNoteNumber,
quoteId,
issueDateFrom,
issueDateTo,
page,
pageSize);
}
public Task<List<SalesDocumentDeliveryNoteItemCandidateDto>> GetDeliveryNoteItemCandidatesForSalesDocumentAsync(IReadOnlyCollection<int> deliveryNoteIds)
{
ArgumentNullException.ThrowIfNull(deliveryNoteIds);
var ids = deliveryNoteIds
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0)
throw new InvalidOperationException("Debe seleccionar al menos un remito emitido.");
return _salesDocumentRepository.GetDeliveryNoteItemCandidatesForSalesDocumentAsync(ids);
}
public async Task<SalesDocumentCreateResponse> CreateFromDeliveryNoteItemsAsync(SalesDocumentCreateFromDeliveryNoteItemsRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(request.Currency))
throw new ArgumentException("La moneda es obligatoria.", nameof(request.Currency));
var selectedQuantities = request.Items
.Where(x => x.DeliveryNoteDetailId > 0)
.GroupBy(x => x.DeliveryNoteDetailId)
.ToDictionary(x => x.Key, x => x.Sum(i => i.SelectedQuantity));
if (selectedQuantities.Count == 0)
throw new InvalidOperationException("Debe seleccionar al menos un item de remito.");
if (selectedQuantities.Any(x => x.Value <= 0))
throw new InvalidOperationException("Todas las cantidades seleccionadas deben ser mayores a cero.");
var candidates = await _salesDocumentRepository.GetDeliveryNoteItemCandidatesByDetailIdsForSalesDocumentAsync(selectedQuantities.Keys.ToList());
if (candidates.Count != selectedQuantities.Count)
throw new InvalidOperationException("Uno o mas items seleccionados no existen o no pertenecen a remitos emitidos.");
foreach (var candidate in candidates)
{
var selectedQuantity = selectedQuantities[candidate.DeliveryNoteDetailId];
if (candidate.PendingQuantity <= 0)
throw new InvalidOperationException($"El item {candidate.Description} del remito {candidate.DeliveryNoteNumber} no tiene saldo pendiente.");
if (selectedQuantity > candidate.PendingQuantity)
throw new InvalidOperationException($"La cantidad seleccionada para {candidate.Description} supera el saldo pendiente.");
if (!candidate.QuoteDetailId.HasValue || candidate.QuoteDetailId.Value <= 0)
throw new InvalidOperationException($"El item {candidate.Description} no tiene detalle de presupuesto asociado.");
if (candidate.ApprovedUnitPrice <= 0)
throw new InvalidOperationException($"El item {candidate.Description} no tiene precio aprobado.");
}
var fiscalCustomerIds = candidates.Select(x => x.CustomerId).Distinct().ToList();
if (fiscalCustomerIds.Count != 1)
throw new InvalidOperationException("No se pueden agrupar items de remitos de distintos clientes fiscales.");
var now = DateTime.Now;
var details = new List<ESalesDocumentDetail>();
var lineNumber = 1;
foreach (var candidate in candidates.OrderBy(x => x.DeliveryNoteIssueDate).ThenBy(x => x.DeliveryNoteId).ThenBy(x => x.LineNumber))
{
var selectedQuantity = selectedQuantities[candidate.DeliveryNoteDetailId];
var selectedAmount = candidate.ApprovedUnitPrice * selectedQuantity;
var billedPercentage = candidate.DeliveredQuantity <= 0
? 0
: Math.Round(selectedQuantity / candidate.DeliveredQuantity * 100, 2);
var originSnapshot = JsonSerializer.Serialize(new
{
deliveryNoteId = candidate.DeliveryNoteId,
deliveryNoteNumber = candidate.DeliveryNoteNumber,
deliveryNoteIssueDate = candidate.DeliveryNoteIssueDate,
deliveryNoteDetailId = candidate.DeliveryNoteDetailId,
deliveryNoteLineNumber = candidate.LineNumber,
quoteId = candidate.QuoteId,
quoteNumber = candidate.QuoteNumber,
quoteDetailId = candidate.QuoteDetailId,
customerId = candidate.CustomerId,
customerName = candidate.CustomerName,
originalQuantity = candidate.DeliveredQuantity,
alreadyBilledQuantity = candidate.AlreadyBilledQuantity,
pendingQuantity = candidate.PendingQuantity,
selectedQuantity,
deliveryNoteExtraInfo = candidate.DeliveryNoteExtraInfoJson
});
var detail = new ESalesDocumentDetail
{
LineNumber = lineNumber++,
OriginType = SalesDocumentOriginType.DeliveryNote.ToStorageCode(),
OriginId = candidate.DeliveryNoteDetailId,
QuoteDetailId = candidate.QuoteDetailId,
ProductId = candidate.ProductId,
Description = candidate.Description.Trim(),
Quantity = selectedQuantity,
AuthorizedUnitPrice = candidate.ApprovedUnitPrice,
AuthorizedAmount = candidate.ApprovedAmount,
BilledPercentage = billedPercentage,
UnitPrice = candidate.ApprovedUnitPrice,
NetAmount = selectedAmount,
TaxAmount = 0,
TotalAmount = selectedAmount,
OriginSnapshotJson = originSnapshot,
Createdat = now
};
if (candidate.QuoteId.HasValue)
{
detail.PhSSalesDocumentCoverages.Add(new ESalesDocumentCoverage
{
QuoteId = candidate.QuoteId.Value,
QuoteDetailId = candidate.QuoteDetailId,
CoverageType = (int)SalesDocumentCoverageType.Direct,
CoveragePercentage = billedPercentage,
CoverageAmount = selectedAmount,
PeriodFrom = request.PeriodFrom,
PeriodTo = request.PeriodTo,
Notes = $"Coverage parcial desde remito {candidate.DeliveryNoteNumber} linea {candidate.LineNumber}",
Createdat = now
});
}
details.Add(detail);
}
var totalAmount = details.Sum(x => x.TotalAmount);
if (totalAmount <= 0)
throw new InvalidOperationException("El total del documento debe ser mayor a cero.");
var quoteIds = candidates
.Where(x => x.QuoteId.HasValue)
.Select(x => x.QuoteId!.Value)
.Distinct()
.ToList();
var operations = candidates
.GroupBy(x => new
{
x.DeliveryNoteId,
x.DeliveryNoteNumber,
x.QuoteId,
x.QuoteNumber,
x.CustomerId,
x.CustomerName,
x.DeliveryNoteIssueDate
})
.Select(x => new SalesDocumentDeliveryNoteOperationSnapshotDto
{
DeliveryNoteId = x.Key.DeliveryNoteId,
DeliveryNoteNumber = x.Key.DeliveryNoteNumber,
QuoteId = x.Key.QuoteId,
QuoteNumber = x.Key.QuoteNumber,
CustomerId = x.Key.CustomerId,
CustomerName = x.Key.CustomerName,
IssueDate = x.Key.DeliveryNoteIssueDate,
Amount = x.Sum(i => i.ApprovedUnitPrice * selectedQuantities[i.DeliveryNoteDetailId]),
Coverage = x.Key.CustomerName
})
.ToList();
var extraInfoJson = JsonSerializer.Serialize(new
{
source = "DeliveryNoteItems",
grouped = operations.Count > 1,
partialBilling = true,
coverageDefinition = "Entidad financiadora / pagadora responsable",
operations
});
var entity = new ESalesDocument
{
DocumentType = request.DocumentType,
Status = (int)SalesDocumentStatus.Draft,
QuoteId = quoteIds.Count == 1 ? quoteIds[0] : null,
CustomerId = fiscalCustomerIds[0],
BillToCustomerId = fiscalCustomerIds[0],
IssueDate = request.IssueDate ?? now,
Currency = request.Currency.Trim(),
ExchangeRate = request.ExchangeRate <= 0 ? 1 : request.ExchangeRate,
NetAmount = totalAmount,
TaxAmount = 0,
TotalAmount = totalAmount,
Observations = request.Observations,
ExtraInfoJson = extraInfoJson,
PeriodFrom = request.PeriodFrom,
PeriodTo = request.PeriodTo,
Createdat = now,
PhSSalesDocumentDetails = details
};
var created = await _salesDocumentRepository.CreateFromDeliveryNoteItemsAsync(entity);
return new SalesDocumentCreateResponse
{
Id = created.Id,
InternalDocumentNumber = created.InternalDocumentNumber
};
}
public async Task<SalesDocumentCreateResponse> CreateFromDeliveryNotesAsync(SalesDocumentCreateFromDeliveryNotesRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var deliveryNoteIds = request.DeliveryNoteIds
.Where(x => x > 0)
.Distinct()
.ToList();
if (deliveryNoteIds.Count == 0)
throw new InvalidOperationException("Debe seleccionar al menos un remito emitido.");
if (string.IsNullOrWhiteSpace(request.Currency))
throw new ArgumentException("La moneda es obligatoria.", nameof(request.Currency));
var deliveryNotes = await _salesDocumentRepository.GetDeliveryNotesForSalesDocumentAsync(deliveryNoteIds);
if (deliveryNotes.Count != deliveryNoteIds.Count)
throw new InvalidOperationException("Uno o más remitos seleccionados no existen.");
var notIssued = deliveryNotes.Where(x => !string.Equals(x.Status, "Emitido", StringComparison.OrdinalIgnoreCase)).ToList();
if (notIssued.Count > 0)
throw new InvalidOperationException("Solo se pueden facturar remitos en estado Emitido.");
var alreadyAssociated = deliveryNotes.Where(x => x.SalesInvoiceId.HasValue && x.SalesInvoiceId.Value > 0).ToList();
if (alreadyAssociated.Count > 0)
throw new InvalidOperationException("Uno o más remitos ya están asociados a un Sales Document.");
var fiscalCustomerIds = deliveryNotes.Select(x => x.CustomerId).Distinct().ToList();
if (fiscalCustomerIds.Count != 1)
throw new InvalidOperationException("No se pueden agrupar remitos de distintos clientes fiscales.");
if (deliveryNotes.Any(x => x.Items.Count == 0))
throw new InvalidOperationException("Todos los remitos seleccionados deben tener ítems.");
var now = DateTime.Now;
var details = new List<ESalesDocumentDetail>();
var coverages = new List<ESalesDocumentCoverage>();
var lineNumber = 1;
foreach (var deliveryNote in deliveryNotes)
{
foreach (var item in deliveryNote.Items)
{
var unitPrice = item.ApprovedUnitPrice ?? item.OriginalUnitPrice ?? 0;
var approvedAmount = item.ApprovedAmount ?? (unitPrice * item.Quantity);
if (approvedAmount <= 0)
throw new InvalidOperationException($"El remito {deliveryNote.DeliveryNoteNumber} contiene ítems sin precio aprobado.");
var originSnapshot = JsonSerializer.Serialize(new
{
deliveryNoteId = deliveryNote.Id,
deliveryNoteNumber = deliveryNote.DeliveryNoteNumber,
deliveryNoteIssueDate = deliveryNote.IssueDate,
quoteId = deliveryNote.QuoteId,
quoteNumber = deliveryNote.QuoteNumber,
customerId = deliveryNote.CustomerId,
customerName = deliveryNote.CustomerName,
deliveryNoteExtraInfo = deliveryNote.ExtraInfoJson
});
details.Add(new ESalesDocumentDetail
{
LineNumber = lineNumber++,
OriginType = SalesDocumentOriginType.DeliveryNote.ToStorageCode(),
OriginId = deliveryNote.Id,
QuoteDetailId = item.QuoteDetailId,
ProductId = item.ProductId,
Description = item.Description.Trim(),
Quantity = item.Quantity,
AuthorizedUnitPrice = unitPrice,
AuthorizedAmount = approvedAmount,
BilledPercentage = 100,
UnitPrice = unitPrice,
NetAmount = approvedAmount,
TaxAmount = 0,
TotalAmount = approvedAmount,
OriginSnapshotJson = originSnapshot,
Createdat = now
});
if (deliveryNote.QuoteId.HasValue)
{
coverages.Add(new ESalesDocumentCoverage
{
QuoteId = deliveryNote.QuoteId.Value,
QuoteDetailId = item.QuoteDetailId,
CoverageType = (int)SalesDocumentCoverageType.Direct,
CoveragePercentage = 100,
CoverageAmount = approvedAmount,
PeriodFrom = request.PeriodFrom,
PeriodTo = request.PeriodTo,
Notes = $"Coverage desde remito {deliveryNote.DeliveryNoteNumber}",
Createdat = now
});
}
}
}
var totalAmount = details.Sum(x => x.TotalAmount);
if (totalAmount <= 0)
throw new InvalidOperationException("El total del documento debe ser mayor a cero.");
var operations = deliveryNotes.Select(x => new SalesDocumentDeliveryNoteOperationSnapshotDto
{
DeliveryNoteId = x.Id,
DeliveryNoteNumber = x.DeliveryNoteNumber,
QuoteId = x.QuoteId,
QuoteNumber = x.QuoteNumber,
CustomerId = x.CustomerId,
CustomerName = x.CustomerName,
IssueDate = x.IssueDate,
Amount = x.Items.Sum(i => i.ApprovedAmount ?? ((i.ApprovedUnitPrice ?? i.OriginalUnitPrice ?? 0) * i.Quantity)),
Coverage = x.CustomerName
}).ToList();
var extraInfoJson = JsonSerializer.Serialize(new
{
source = "DeliveryNotes",
grouped = deliveryNotes.Count > 1,
coverageDefinition = "Entidad financiadora / pagadora responsable",
operations
});
var entity = new ESalesDocument
{
DocumentType = request.DocumentType,
Status = (int)SalesDocumentStatus.Draft,
QuoteId = deliveryNotes.Count == 1 ? deliveryNotes[0].QuoteId : 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,
PhSSalesDocumentCoverages = coverages
};
var created = await _salesDocumentRepository.CreateFromDeliveryNotesAsync(entity, deliveryNoteIds);
return new SalesDocumentCreateResponse
{
Id = created.Id,
InternalDocumentNumber = created.InternalDocumentNumber
};
}
public async Task<SalesDocumentCreateResponse> CreateAsync(SalesDocumentCreateRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.CustomerId <= 0)
throw new ArgumentException("Debe seleccionar un cliente.", nameof(request.CustomerId));
if (request.BillToCustomerId <= 0)
throw new ArgumentException("Debe seleccionar un cliente de facturación.", nameof(request.BillToCustomerId));
if (string.IsNullOrWhiteSpace(request.Currency))
throw new ArgumentException("La moneda es obligatoria.", nameof(request.Currency));
if (request.Details is null || request.Details.Count == 0)
throw new InvalidOperationException("Debe incluir al menos un detail.");
if (request.Coverage is null || request.Coverage.Count == 0)
throw new InvalidOperationException("Debe incluir coverage.");
foreach (var detail in request.Details)
ValidateDetail(detail);
var netAmount = request.Details.Sum(x => x.NetAmount);
var taxAmount = request.Details.Sum(x => x.TaxAmount);
var totalAmount = request.Details.Sum(x => x.TotalAmount);
if (totalAmount <= 0)
throw new InvalidOperationException("El total del documento debe ser mayor a cero.");
var now = DateTime.Now;
var entity = new ESalesDocument
{
FormseriesId = request.FormseriesId,
DocumentType = request.DocumentType,
FiscalVoucherType = request.FiscalVoucherType,
FiscalVoucherLetter = request.FiscalVoucherLetter,
Status = (int)SalesDocumentStatus.Draft,
QuoteId = request.QuoteId,
CustomerId = request.CustomerId,
BillToCustomerId = request.BillToCustomerId,
IssueDate = request.IssueDate ?? now,
Currency = request.Currency.Trim(),
ExchangeRate = request.ExchangeRate <= 0 ? 1 : request.ExchangeRate,
NetAmount = netAmount,
TaxAmount = taxAmount,
TotalAmount = totalAmount,
AssociatedDocumentType = request.AssociatedDocumentType,
AssociatedDocumentNumber = request.AssociatedDocumentNumber,
AssociatedDocumentDate = request.AssociatedDocumentDate,
Observations = request.Observations,
ExtraInfoJson = request.ExtraInfoJson,
PeriodFrom = request.PeriodFrom,
PeriodTo = request.PeriodTo,
Createdat = now,
PhSSalesDocumentDetails = request.Details.Select(x => new ESalesDocumentDetail
{
LineNumber = x.LineNumber,
OriginType = x.OriginType.ToStorageCode(),
OriginId = ResolveOriginId(x),
QuoteDetailId = ResolveQuoteDetailId(x),
ProductId = x.ProductId,
Description = x.Description.Trim(),
Quantity = x.Quantity,
AuthorizedUnitPrice = x.AuthorizedUnitPrice,
AuthorizedAmount = x.AuthorizedAmount,
BilledPercentage = x.BilledPercentage,
UnitPrice = x.UnitPrice,
NetAmount = x.NetAmount,
TaxAmount = x.TaxAmount,
TotalAmount = x.TotalAmount,
OriginSnapshotJson = x.OriginSnapshotJson,
Createdat = now
}).ToList(),
PhSSalesDocumentCoverages = request.Coverage.Select(x => new ESalesDocumentCoverage
{
QuoteId = x.QuoteId,
QuoteDetailId = x.QuoteDetailId,
CoverageType = x.CoverageType,
CoveragePercentage = x.CoveragePercentage,
CoverageAmount = x.CoverageAmount,
PeriodFrom = x.PeriodFrom,
PeriodTo = x.PeriodTo,
Notes = x.Notes,
Createdat = now
}).ToList()
};
var created = await _salesDocumentRepository.CreateAsync(entity);
return new SalesDocumentCreateResponse
{
Id = created.Id,
InternalDocumentNumber = created.InternalDocumentNumber
};
}
public Task<SalesDocumentDto?> GetDtoByIdAsync(int id)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
return _salesDocumentRepository.GetDtoByIdAsync(id);
}
public async Task<SalesDocumentDraftPreviewDto?> GetDraftPreviewAsync(int id)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
var document = await _salesDocumentRepository.GetDtoByIdAsync(id);
return document == null ? null : BuildDraftPreview(document);
}
public async Task<SalesDocumentDraftPreviewDto?> UpdateDraftReviewAsync(int id, SalesDocumentDraftReviewDto review)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
ArgumentNullException.ThrowIfNull(review);
var current = await _salesDocumentRepository.GetDtoByIdAsync(id);
if (current == null)
return null;
if (current.Status != (int)SalesDocumentStatus.Draft)
throw new InvalidOperationException("Solo se pueden revisar Sales Documents en estado Draft.");
var updated = await _salesDocumentRepository.UpdateDraftReviewAsync(id, review);
return updated == null ? null : BuildDraftPreview(updated);
}
public async Task<SalesDocumentDraftPreviewDto?> ValidateDraftAsync(int id)
{
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id));
var current = await _salesDocumentRepository.GetDtoByIdAsync(id);
if (current == null)
return null;
var validation = BuildDraftValidation(current);
if (!validation.IsValid)
throw new InvalidOperationException(string.Join(" ", validation.Errors));
var validated = await _salesDocumentRepository.ValidateDraftAsync(id);
return validated == null ? null : BuildDraftPreview(validated);
}
private static SalesDocumentDraftPreviewDto BuildDraftPreview(SalesDocumentDto document)
{
return new SalesDocumentDraftPreviewDto
{
Document = document,
OriginDeliveryNotes = BuildOriginDeliveryNotes(document),
Validation = BuildDraftValidation(document)
};
}
private static SalesDocumentDraftValidationDto BuildDraftValidation(SalesDocumentDto document)
{
var validation = new SalesDocumentDraftValidationDto
{
HasDetails = document.Details.Any(),
HasValidAmounts = document.TotalAmount > 0 && document.NetAmount >= 0 && document.TaxAmount >= 0,
HasCustomer = document.CustomerId > 0,
IsDraft = document.Status == (int)SalesDocumentStatus.Draft
};
if (!validation.HasDetails)
validation.Errors.Add("El documento debe tener detalles.");
if (!validation.HasValidAmounts)
validation.Errors.Add("El documento debe tener importes validos.");
if (!validation.HasCustomer)
validation.Errors.Add("El documento debe tener cliente asignado.");
if (!validation.IsDraft)
validation.Errors.Add("El documento debe permanecer en estado Draft.");
return validation;
}
private static List<DeliveryNoteSummaryDto> BuildOriginDeliveryNotes(SalesDocumentDto document)
{
return document.Details
.Where(x => string.Equals(x.OriginType, SalesDocumentOriginType.DeliveryNote.ToStorageCode(), StringComparison.OrdinalIgnoreCase))
.Select(TryBuildDeliveryNoteSummary)
.Where(x => x is not null)
.Select(x => x!)
.GroupBy(x => x.Id)
.Select(x => x.First())
.OrderBy(x => x.IssueDate)
.ThenBy(x => x.Id)
.ToList();
}
private static DeliveryNoteSummaryDto? TryBuildDeliveryNoteSummary(SalesDocumentDetailDto detail)
{
if (string.IsNullOrWhiteSpace(detail.OriginSnapshotJson))
{
return detail.OriginId.HasValue
? new DeliveryNoteSummaryDto
{
Id = detail.OriginId.Value,
DeliveryNoteNumber = $"Remito #{detail.OriginId.Value}"
}
: null;
}
try
{
using var jsonDocument = JsonDocument.Parse(detail.OriginSnapshotJson);
var root = jsonDocument.RootElement;
var deliveryNoteId = root.TryGetProperty("deliveryNoteId", out var idProperty) && idProperty.TryGetInt32(out var parsedId)
? parsedId
: detail.OriginId;
if (!deliveryNoteId.HasValue)
return null;
var issueDate = DateTime.MinValue;
if (root.TryGetProperty("deliveryNoteIssueDate", out var dateProperty) &&
dateProperty.ValueKind == JsonValueKind.String &&
DateTime.TryParse(dateProperty.GetString(), out var parsedDate))
{
issueDate = parsedDate;
}
return new DeliveryNoteSummaryDto
{
Id = deliveryNoteId.Value,
DeliveryNoteNumber = ReadString(root, "deliveryNoteNumber") ?? $"Remito #{deliveryNoteId.Value}",
QuoteId = ReadInt(root, "quoteId"),
QuoteNumber = ReadString(root, "quoteNumber"),
IssueDate = issueDate,
CustomerId = ReadInt(root, "customerId") ?? 0,
CustomerName = ReadString(root, "customerName") ?? string.Empty,
Status = "Emitido"
};
}
catch
{
return detail.OriginId.HasValue
? new DeliveryNoteSummaryDto
{
Id = detail.OriginId.Value,
DeliveryNoteNumber = $"Remito #{detail.OriginId.Value}"
}
: null;
}
}
private static string? ReadString(JsonElement root, string propertyName)
{
return root.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static int? ReadInt(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var property))
return null;
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
return value;
return null;
}
private static void ValidateDetail(SalesDocumentCreateDetailRequest detail)
{
if (detail.LineNumber <= 0)
throw new ArgumentException("El número de línea del detail debe ser mayor a cero.", nameof(detail.LineNumber));
if (!Enum.IsDefined(typeof(SalesDocumentOriginType), detail.OriginType))
throw new ArgumentException("El tipo de origen del detail no es válido.", nameof(detail.OriginType));
if (string.IsNullOrWhiteSpace(detail.Description))
throw new ArgumentException("La descripción del detail es obligatoria.", nameof(detail.Description));
if (detail.Quantity <= 0)
throw new ArgumentException("La cantidad del detail debe ser mayor a cero.", nameof(detail.Quantity));
var hasOriginId = detail.OriginId.HasValue && detail.OriginId.Value > 0;
var hasQuoteDetailId = detail.QuoteDetailId.HasValue && detail.QuoteDetailId.Value > 0;
if (detail.OriginType != SalesDocumentOriginType.Manual && !hasOriginId && !hasQuoteDetailId)
throw new ArgumentException("Debe informar OriginId o QuoteDetailId para trazabilidad del origen.", nameof(detail.OriginId));
if (detail.OriginType == SalesDocumentOriginType.QuoteDetail && !hasQuoteDetailId && !hasOriginId)
throw new ArgumentException("Debe informar QuoteDetailId u OriginId para líneas originadas en presupuesto.", nameof(detail.QuoteDetailId));
}
private static int? ResolveOriginId(SalesDocumentCreateDetailRequest detail)
{
if (detail.OriginId.HasValue && detail.OriginId.Value > 0)
return detail.OriginId;
return detail.OriginType == SalesDocumentOriginType.QuoteDetail
? detail.QuoteDetailId
: null;
}
private static int? ResolveQuoteDetailId(SalesDocumentCreateDetailRequest detail)
{
if (detail.QuoteDetailId.HasValue && detail.QuoteDetailId.Value > 0)
return detail.QuoteDetailId;
return detail.OriginType == SalesDocumentOriginType.QuoteDetail
? detail.OriginId
: null;
}
}
}