phronCare/Models/Repositories/Stock/PhLSMExpeditionRepository.cs
leandro 915f78bb40
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 4m23s
feat(stock): reserve stock when expedition moves to EnTransito
Closes #9
2026-03-15 19:17:26 -03:00

569 lines
23 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <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})"
};
// ===========================
// 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;
}
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<PhLsmExpeditionDetail>();
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<string>();
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<string>
{
"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;
}
}
}
}