From 915f78bb40b245f937206c7087b7f3b6dde721a0 Mon Sep 17 00:00:00 2001 From: leandro Date: Sun, 15 Mar 2026 19:17:26 -0300 Subject: [PATCH] feat(stock): reserve stock when expedition moves to EnTransito Closes #9 --- .gitignore | 4 + Models/Interfaces/IExpeditionRepository.cs | 4 + .../Stock/PhLSMExpeditionRepository.cs | 151 +++++++++++++++++- .../Stock/PhLSMStockReservationRepository.cs | 7 - 4 files changed, 155 insertions(+), 11 deletions(-) delete mode 100644 Models/Repositories/Stock/PhLSMStockReservationRepository.cs diff --git a/.gitignore b/.gitignore index 86b9b6c..183c189 100644 --- a/.gitignore +++ b/.gitignore @@ -397,6 +397,10 @@ FodyWeavers.xsd *.msm *.msp +# Patch files (temporary) +*.patch +*.diff + # JetBrains Rider *.sln.iml /Core/obj/Debug/net8.0/Core.csproj.FileListAbsolute.txt diff --git a/Models/Interfaces/IExpeditionRepository.cs b/Models/Interfaces/IExpeditionRepository.cs index b1862bb..153f1ee 100644 --- a/Models/Interfaces/IExpeditionRepository.cs +++ b/Models/Interfaces/IExpeditionRepository.cs @@ -23,6 +23,10 @@ namespace Models.Interfaces Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId); Task GetDtoByIdAsync(int id); Task> SearchAsync(string? expeditionNumber, string? status, DateTime? issueDateFrom, DateTime? issueDateTo, int? locationId, int page, int pageSize); + /// + /// Pasa la expedición a En tránsito y crea las reservas de stock asociadas. + /// La operación es transaccional y falla completa si detecta inconsistencias. + /// Task MarkInTransitAsync(int expeditionId); } diff --git a/Models/Repositories/Stock/PhLSMExpeditionRepository.cs b/Models/Repositories/Stock/PhLSMExpeditionRepository.cs index 6d27217..d39a81b 100644 --- a/Models/Repositories/Stock/PhLSMExpeditionRepository.cs +++ b/Models/Repositories/Stock/PhLSMExpeditionRepository.cs @@ -406,7 +406,11 @@ namespace Models.Repositories.Stock } 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) @@ -415,11 +419,150 @@ namespace Models.Repositories.Stock 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; + var details = header.PhLsmExpeditionDetails?.ToList() ?? new List(); - await _context.SaveChangesAsync(); + 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; + } } } - } diff --git a/Models/Repositories/Stock/PhLSMStockReservationRepository.cs b/Models/Repositories/Stock/PhLSMStockReservationRepository.cs deleted file mode 100644 index d1b2466..0000000 --- a/Models/Repositories/Stock/PhLSMStockReservationRepository.cs +++ /dev/null @@ -1,7 +0,0 @@ - -namespace Models.Repositories.Stock -{ - internal class PhLSMStockReservationRepository - { - } -}