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> 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> 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 async Task 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(); var coverages = new List(); 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 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 GetDtoByIdAsync(int id) { if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id)); return _salesDocumentRepository.GetDtoByIdAsync(id); } public async Task 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 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 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 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; } } }