phronCare/Models/Repositories/Stock/PhLSMExpeditionRepository.cs

426 lines
17 KiB
C#
Raw Normal View History

2025-09-04 18:15:15 -03:00
using Domain.Constants;
using Domain.Dtos.Stock;
using Domain.Entities;
2025-09-05 16:31:58 -03:00
using Domain.Generics;
using Microsoft.Data.SqlClient;
2025-09-04 18:15:15 -03:00
using Microsoft.EntityFrameworkCore;
using Models.Helpers;
using Models.Interfaces;
using Models.Models;
using System.Data;
2025-09-05 16:31:58 -03:00
using System.Globalization;
using System.Text;
2025-09-04 18:15:15 -03:00
namespace Models.Repositories.Stock
{
public class PhLSMExpeditionRepository(
PhronCareOperationsHubContext context,
IPhSFormSeriesRepository formSeriesRepository) : IExpeditionRepository
{
private readonly PhronCareOperationsHubContext _context = context;
private readonly IPhSFormSeriesRepository _formSeriesRepository = formSeriesRepository;
/// <summary>
/// Crea la expedición completa (header + details) con numeración de serie y estado emitido.
/// </summary>
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<ELSExpeditionHeader, PhLsmExpeditionHeader>(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;
}
}
/// <summary>
/// Devuelve el DTO completo de Expedición (cabecera + ítems) listo para UI/impresión.
/// </summary>
public async Task<ExpeditionDto?> 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<int, (string? Name, string? Descripcion, string? FactoryCode, string? ExternalCode, string? RegulatoryCode)>()
: 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<int, (string Name, string? Address)>()
// : 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 -----
/// <summary>
/// Mapea el estado entero a etiqueta amigable (enum: Emitida=1, EnTransito=2, EnDestino=3, Retorno=4, Cerrada=5, Anulada=6).
/// </summary>
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})"
};
2025-09-05 16:31:58 -03:00
// ===========================
// BÚSQUEDA + PAGINACIÓN
// ===========================
public async Task<PagedResult<ExpeditionDto>> 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<ExpeditionDto>
{
Items = headers,
TotalItems = total,
Page = page,
PageSize = pageSize
};
}
public async Task<List<StockItemExpeditionConflictDto>> CheckStockItemConflictsAsync(
IEnumerable<int> stockItemIds,
int? ignoreExpeditionId)
{
// Normalización defensiva
var ids = (stockItemIds ?? Enumerable.Empty<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0)
return new List<StockItemExpeditionConflictDto>();
// 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<StockItemExpeditionConflictDto>();
// 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;
}
2025-09-05 16:31:58 -03:00
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)
{
var header = await _context.PhLsmExpeditionHeaders
.FirstOrDefaultAsync(x => x.Id == expeditionId);
if (header == null)
throw new KeyNotFoundException($"No se encontró la expedición con ID {expeditionId}.");
2025-09-05 16:31:58 -03:00
if (header.Status != (int)ExpeditionStatus.Emitida)
throw new InvalidOperationException("Solo las expediciones en estado 'Emitida' pueden pasar a 'En tránsito'.");
header.Status = (int)ExpeditionStatus.EnTransito;
header.Modifiedat = DateTime.Now;
await _context.SaveChangesAsync();
}
2025-09-04 18:15:15 -03:00
}
}