All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 26m7s
534 lines
23 KiB
C#
534 lines
23 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 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;
|
|
}
|
|
|
|
}
|
|
}
|