feat(stock): reserve stock when expedition moves to EnTransito
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 4m23s

Closes #9
This commit is contained in:
Leandro Hernan Rojas 2026-03-15 19:17:26 -03:00
parent b2aebafe55
commit 915f78bb40
4 changed files with 155 additions and 11 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -23,6 +23,10 @@ namespace Models.Interfaces
Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId);
Task<ExpeditionDto?> GetDtoByIdAsync(int id);
Task<PagedResult<ExpeditionDto>> SearchAsync(string? expeditionNumber, string? status, DateTime? issueDateFrom, DateTime? issueDateTo, int? locationId, int page, int pageSize);
/// <summary>
/// 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.
/// </summary>
Task MarkInTransitAsync(int expeditionId);
}

View File

@ -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'.");
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 = DateTime.Now;
header.Modifiedat = now;
await _context.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
}
}
}

View File

@ -1,7 +0,0 @@

namespace Models.Repositories.Stock
{
internal class PhLSMStockReservationRepository
{
}
}