using Domain.Constants; using Domain.Dtos.Stock; using Domain.Entities; using Domain.Generics; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Models.Helpers; using Models.Interfaces; using Models.Models; using System.Data; using System.Globalization; using System.Text; namespace Models.Repositories.Stock { public class PhLSMExpeditionRepository( PhronCareOperationsHubContext context, IPhSFormSeriesRepository formSeriesRepository) : IExpeditionRepository { private readonly PhronCareOperationsHubContext _context = context; private readonly IPhSFormSeriesRepository _formSeriesRepository = formSeriesRepository; /// /// Crea la expedición completa (header + details) con numeración de serie y estado emitido. /// public async Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync( ELSExpeditionHeader expedition, int formSeriesId) { using var tx = await _context.Database.BeginTransactionAsync(); try { // 1) Numeración (EX-00000000) – mismo patrón que Quotes var next = await _formSeriesRepository.GetNextInternalNumberAsync(formSeriesId); var series = await _formSeriesRepository.GetByIdAsync(formSeriesId) ?? throw new InvalidOperationException("Serie no encontrada"); var number = $"{series.Letter}-{next:D8}"; var issuedAt = DateTime.Now; // 2) Completar datos de emisión en el agregado de dominio expedition.Expeditionnumber = number; expedition.Issuedate = issuedAt; expedition.Status = (int)ExpeditionStatus.Emitida; // 3) Mapear grafo completo Domain -> EF (Header + Details) // Igual que haces en CreateFullQuoteAsync con EntityMapper.MapEntity(...) var headerEntity = EntityMapper.MapEntity(expedition); // 4) Persistir de una (header + colecciones) y confirmar _context.PhLsmExpeditionHeaders.Add(headerEntity); await _context.SaveChangesAsync(); await tx.CommitAsync(); return (headerEntity.Id, headerEntity.Expeditionnumber); } catch { await tx.RollbackAsync(); throw; } } /// /// Devuelve el DTO completo de Expedición (cabecera + ítems) listo para UI/impresión. /// public async Task GetDtoByIdAsync(int id) { // 1) Header + detalles var header = await _context.PhLsmExpeditionHeaders .AsNoTracking() .Include(h => h.PhLsmExpeditionDetails) .FirstOrDefaultAsync(h => h.Id == id); if (header is null) return null; // 2) Resolver productos (un solo round-trip) var productIds = header.PhLsmExpeditionDetails .Select(d => d.ProductId) .Distinct() .ToList(); var productMap = productIds.Count == 0 ? new Dictionary() : await _context.PhLsmProducts .Where(p => productIds.Contains(p.Id)) .Select(p => new { p.Id, p.Name, p.Descripcion, p.FactoryCode, // código fábrica (preferido en impresión) p.ExternalCode, // GTIN p.RegulatoryCode // PM }) .ToDictionaryAsync( p => p.Id, p => (p.Name, p.Descripcion, p.FactoryCode, p.ExternalCode, p.RegulatoryCode) ); //// 3) Resolver nombres de ubicaciones (si corresponde) //var locationIds = header.PhLsmExpeditionDetails // .Select(d => d.LocationId) // .Where(l => l.HasValue) // .Select(l => l!.Value) // .Distinct() // .ToList(); //var locationMap = locationIds.Count == 0 // ? new Dictionary() // : await _context.PhLsmStockLocations // .Where(l => locationIds.Contains(l.Id)) // .Select(l => new { l.Id, l.Name, l.Address }) // Address opcional // .ToDictionaryAsync(l => l.Id, l => (l.Name, l.Address)); // 4) Proyección a DTO (ítems) var items = header.PhLsmExpeditionDetails.Select(d => { productMap.TryGetValue(d.ProductId, out var p); var productName = !string.IsNullOrWhiteSpace(p.Name) ? p.Name : (!string.IsNullOrWhiteSpace(p.Descripcion) ? p.Descripcion : string.Empty); //var locationName = (d.LocationId.HasValue && locationMap.TryGetValue(d.LocationId.Value, out var ln)) // ? ln.Name // : null; return new ExpeditionItemDto { Id = d.Id, ProductId = d.ProductId, FactoryCode = p.FactoryCode ?? string.Empty, // preferido para mostrar ProductName = productName, Quantity = d.Quantity, Batch = d.Batch, Serial = d.Serial, Expiration = d.Expiration, LocationId = d.LocationId, LocationName = string.Empty, // locationName, // si lo querés mostrar }; }).ToList(); // 5) Completar cabecera del DTO var dto = new ExpeditionDto { Id = header.Id, Expeditionnumber = header.Expeditionnumber, Issuedate = header.Issuedate, Status = header.Status, StatusLabel = MapStatus(header.Status), ExtrainfoJson = header.ExtrainfoJson, // se arma en el momento de imprimir, como definiste Observations = header.Observations, // Opcional: si el header tiene BusinessUnitId / SeriesId, podés resolver aquí sus códigos/nombres. Items = items }; //// 6) Si todos los detalles comparten la misma ubicación, reflejarla en cabecera (útil para impresión) //var distinctLocs = items.Select(i => i.LocationId).Where(x => x.HasValue).Distinct().ToList(); //if (distinctLocs.Count == 1) //{ // dto.LocationId = distinctLocs[0]; // if (dto.LocationId.HasValue && locationMap.TryGetValue(dto.LocationId.Value, out var ln)) // { // dto.LocationName = ln.Name; // // dto.LocationAddress = ln.Address; // si tu DTO lo contempla // } //} return dto; } // ----- helpers ----- /// /// Mapea el estado entero a etiqueta amigable (enum: Emitida=1, EnTransito=2, EnDestino=3, Retorno=4, Cerrada=5, Anulada=6). /// private static string MapStatus(int status) => status switch { 1 => "Emitida", 2 => "En tránsito", 3 => "En destino", 4 => "Retorno", 5 => "Cerrada", 6 => "Anulada", _ => $"Desconocido ({status})" }; // =========================== // BÚSQUEDA + PAGINACIÓN // =========================== public async Task> SearchAsync( string? expeditionNumber, string? status, DateTime? issueDateFrom, DateTime? issueDateTo, int? locationId, int page, int pageSize) { if (page <= 0) page = 1; if (pageSize <= 0) pageSize = 10; // NOTE: ajustá el DbSet si tu entidad se llama distinto var q = _context.PhLsmExpeditionHeaders .AsNoTracking() .AsQueryable(); // Número if (!string.IsNullOrWhiteSpace(expeditionNumber)) { var num = expeditionNumber.Trim(); q = q.Where(h => EF.Functions.Like(h.Expeditionnumber!, $"%{num}%")); } // Estado (acepta etiqueta o número) if (!string.IsNullOrWhiteSpace(status)) { var st = MapStatusLabelToInt(status); if (st.HasValue) q = q.Where(h => h.Status == st.Value); } // Fechas (inclusivo) if (issueDateFrom.HasValue) { var from = issueDateFrom.Value.Date; q = q.Where(h => h.Issuedate >= from); } if (issueDateTo.HasValue) { var toExclusive = issueDateTo.Value.Date.AddDays(1); q = q.Where(h => h.Issuedate < toExclusive); } // Ubicación (si la cabecera no la tiene, filtramos por detalles) if (locationId.HasValue) { q = q.Where(h => h.PhLsmExpeditionDetails.Any(d => d.LocationId == locationId.Value)); } var total = await q.CountAsync(); // Página (más recientes primero) var headers = await q .OrderByDescending(h => h.Issuedate) .ThenByDescending(h => h.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(h => new ExpeditionDto { Id = h.Id, Expeditionnumber = h.Expeditionnumber!, Issuedate = h.Issuedate, // no-nullable Status = h.Status, StatusLabel = MapStatus(h.Status), ExternalReference=h.ExternalReference, ExtrainfoJson = h.ExtrainfoJson, Observations = h.Observations, Printcount = h.Printcount, // ← ajustá si tu entidad usa otro nombre Createdat = h.Createdat, // ← idem Modifiedat = h.Modifiedat, // ← idem LocationId = 0, // por defecto (se resuelve si todos los ítems comparten) LocationName = null }) .ToListAsync(); if (headers.Count > 0) { var headerIds = headers.Select(x => x.Id).ToList(); // Distintas ubicaciones por header (en base a detalles) var locsPerHeader = await _context.PhLsmExpeditionDetails .AsNoTracking() .Where(d => headerIds.Contains(d.ExpeditionId)) // ← ajustá si es HeaderId .GroupBy(d => d.ExpeditionId) .Select(g => new { HeaderId = g.Key, DistinctLocs = g.Select(x => x.LocationId).Distinct().ToList() }) .ToListAsync(); // Diccionario de nombres de ubicación //var allLocIds = locsPerHeader.SelectMany(x => x.DistinctLocs).Distinct().ToList(); //var locNames = await _context.PhLsmStockLocations // .AsNoTracking() // .Where(l => allLocIds.Contains(l.Id)) // .Select(l => new { l.Id, l.Name }) // .ToDictionaryAsync(x => x.Id, x => x.Name); //// Asignar LocationId/Name sólo si TODOS los items comparten la misma //foreach (var h in headers) //{ // var entry = locsPerHeader.FirstOrDefault(x => x.HeaderId == h.Id); // if (entry is null) continue; // if (entry.DistinctLocs.Count == 1) // { // var lid = entry.DistinctLocs[0]; // h.LocationId = lid; // if (locNames.TryGetValue(lid, out var lname)) // h.LocationName = lname; // } // else // { // // varias ubicaciones: dejamos LocationId=0 y LocationName=null // } //} } return new PagedResult { Items = headers, TotalItems = total, Page = page, PageSize = pageSize }; } public async Task> CheckStockItemConflictsAsync( IEnumerable stockItemIds, int? ignoreExpeditionId) { // Normalización defensiva var ids = (stockItemIds ?? Enumerable.Empty()) .Where(x => x > 0) .Distinct() .ToList(); if (ids.Count == 0) return new List(); // TVP: dbo.PhLSM_StockItemIdList(stockitem_id int not null) var tvp = new DataTable(); tvp.Columns.Add("stockitem_id", typeof(int)); foreach (var id in ids) tvp.Rows.Add(id); var results = new List(); // Usamos la conexión del DbContext (no creamos otra) var conn = _context.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) await _context.Database.OpenConnectionAsync(); await using var cmd = conn.CreateCommand(); cmd.CommandText = "dbo.PhLSM_Expedition_CheckStockItemConflicts"; cmd.CommandType = CommandType.StoredProcedure; // Param TVP var pIds = new SqlParameter("@StockItemIds", SqlDbType.Structured) { TypeName = "dbo.PhLSM_StockItemIdList", Value = tvp }; cmd.Parameters.Add(pIds); // Param opcional para edición var pIgnore = new SqlParameter("@IgnoreExpeditionId", SqlDbType.Int) { Value = ignoreExpeditionId.HasValue ? ignoreExpeditionId.Value : DBNull.Value }; cmd.Parameters.Add(pIgnore); await using var reader = await cmd.ExecuteReaderAsync(); // Ordinals por nombre (más robusto ante cambios de orden) var ordStockItemId = reader.GetOrdinal("StockitemId"); var ordExpId = reader.GetOrdinal("ExpeditionId"); var ordExpNum = reader.GetOrdinal("Expeditionnumber"); var ordStatus = reader.GetOrdinal("Status"); while (await reader.ReadAsync()) { results.Add(new StockItemExpeditionConflictDto { StockitemId = reader.GetInt32(ordStockItemId), ExpeditionId = reader.GetInt32(ordExpId), Expeditionnumber = reader.IsDBNull(ordExpNum) ? string.Empty : reader.GetString(ordExpNum), Status = reader.GetInt32(ordStatus) }); } return results; } private static int? MapStatusLabelToInt(string labelOrNumber) { if (string.IsNullOrWhiteSpace(labelOrNumber)) return null; if (int.TryParse(labelOrNumber, out var n) && n is >= 1 and <= 6) return n; var key = NormalizeKey(labelOrNumber); return key switch { "emitida" => 1, "entransito" => 2, // cubre "en transito" y "en tránsito" "endestino" => 3, "retorno" => 4, "cerrada" => 5, "anulada" => 6, _ => (int?)null }; } private static string NormalizeKey(string s) { var norm = s.Trim().ToLowerInvariant().Normalize(NormalizationForm.FormD); var sb = new StringBuilder(norm.Length); foreach (var ch in norm) if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) sb.Append(ch); return sb.ToString().Normalize(NormalizationForm.FormC).Replace(" ", ""); } public async Task MarkInTransitAsync(int expeditionId) { const byte expeditionReservationSourceType = 1; const int reservedStatus = 1; var header = await _context.PhLsmExpeditionHeaders .Include(x => x.PhLsmExpeditionDetails) .FirstOrDefaultAsync(x => x.Id == expeditionId); if (header == null) throw new KeyNotFoundException($"No se encontró la expedición con ID {expeditionId}."); if (header.Status != (int)ExpeditionStatus.Emitida) throw new InvalidOperationException("Solo las expediciones en estado 'Emitida' pueden pasar a 'En tránsito'."); var details = header.PhLsmExpeditionDetails?.ToList() ?? new List(); if (details.Count == 0) throw new InvalidOperationException("No se puede pasar la expedición a 'En tránsito' porque no tiene ítems para reservar."); var invalidStockItems = details .Where(d => d.StockitemId <= 0) .Select(d => d.Id) .OrderBy(x => x) .ToList(); if (invalidStockItems.Count > 0) { throw new InvalidOperationException( "No se puede pasar la expedición a 'En tránsito' porque existen detalles sin stockitem_id válido. " + $"Detalle(s): {string.Join(", ", invalidStockItems)}"); } var duplicateStockItems = details .GroupBy(d => d.StockitemId) .Where(g => g.Count() > 1) .Select(g => g.Key) .OrderBy(x => x) .ToList(); if (duplicateStockItems.Count > 0) { throw new InvalidOperationException( "No se puede pasar la expedición a 'En tránsito' porque el mismo StockItem aparece más de una vez en la expedición: " + string.Join(", ", duplicateStockItems)); } var detailByStockItem = details .Select(d => new { DetailId = d.Id, StockitemId = d.StockitemId, Quantity = d.Quantity }) .ToList(); var stockItemIds = detailByStockItem .Select(x => x.StockitemId) .Distinct() .ToList(); var duplicatedReservations = await _context.PhLsmStockReservations .AsNoTracking() .Where(r => r.SourceType == expeditionReservationSourceType && r.SourceId == expeditionId && r.Status == reservedStatus && stockItemIds.Contains(r.StockitemId)) .Select(r => r.StockitemId) .Distinct() .OrderBy(x => x) .ToListAsync(); if (duplicatedReservations.Count > 0) { throw new InvalidOperationException( "La expedición ya posee reservas activas para los siguientes StockItem: " + string.Join(", ", duplicatedReservations)); } var stockItems = await _context.PhLsmStockItems .Where(x => stockItemIds.Contains(x.Id)) .ToListAsync(); var stockItemsById = stockItems.ToDictionary(x => x.Id); var missingStockItems = stockItemIds .Where(id => !stockItemsById.ContainsKey(id)) .OrderBy(x => x) .ToList(); if (missingStockItems.Count > 0) { throw new InvalidOperationException( "No se puede pasar la expedición a 'En tránsito' porque algunos StockItem no existen: " + string.Join(", ", missingStockItems)); } var insufficientAvailability = new List(); foreach (var item in detailByStockItem.OrderBy(x => x.StockitemId)) { var stockItem = stockItemsById[item.StockitemId]; var availableQuantity = stockItem.Quantity - stockItem.ReservedQuantity; if (item.Quantity > availableQuantity) { insufficientAvailability.Add( $"• StockItem {item.StockitemId} → solicitado: {item.Quantity}, disponible: {availableQuantity}."); } } if (insufficientAvailability.Count > 0) { var lines = new List { "No se puede pasar la expedición a 'En tránsito' porque algunos StockItem no tienen cantidad disponible suficiente para reservar." }; lines.AddRange(insufficientAvailability); throw new InvalidOperationException(string.Join(Environment.NewLine, lines)); } using var tx = await _context.Database.BeginTransactionAsync(); try { var now = DateTime.Now; var reservations = detailByStockItem.Select(item => new PhLsmStockReservation { SourceType = expeditionReservationSourceType, SourceId = expeditionId, StockitemId = item.StockitemId, ReservedQuantity = item.Quantity, Status = reservedStatus, Createdat = now }).ToList(); _context.PhLsmStockReservations.AddRange(reservations); foreach (var item in detailByStockItem) { var stockItem = stockItemsById[item.StockitemId]; stockItem.ReservedQuantity += item.Quantity; stockItem.Modifiedat = now; } header.Status = (int)ExpeditionStatus.EnTransito; header.Modifiedat = now; await _context.SaveChangesAsync(); await tx.CommitAsync(); } catch { await tx.RollbackAsync(); throw; } } } }