feat(expeditions): prevent reuse of stockitem_id in active expeditions #6

Merged
leandro merged 1 commits from feature/leandro/5-double-trace-lock into master 2026-03-12 02:38:27 +00:00
11 changed files with 413 additions and 16 deletions
Showing only changes of commit 6d0a72c01d - Show all commits

View File

@ -1,4 +1,5 @@
using Core.Interfaces.Stock;
using Domain.Constants;
using Domain.Dtos.Stock;
using Domain.Entities;
using Domain.Generics;
@ -12,32 +13,155 @@ namespace Core.Services.Stock
{
#region Declaraciones
private readonly IExpeditionRepository _repo;
public ExpeditionService(IExpeditionRepository repo) => _repo = repo;
private readonly IPhLSMStockItemRepository _stockItemRepository;
public ExpeditionService(
IExpeditionRepository repo,
IPhLSMStockItemRepository stockItemRepository)
{
_repo = repo;
_stockItemRepository = stockItemRepository;
}
#endregion
#region Guardado completo de expedicion (encabezado + detalles)
public async Task<(int Id, string ExpeditionNumber)> CreateAndIssueAsync(
ELSExpeditionHeader header,
IEnumerable<ELSExpeditionDetail> details,
int formSeriesId)
{
if (header is null) throw new ArgumentNullException(nameof(header));
if (header is null)
throw new ArgumentNullException(nameof(header));
if (details is null || !details.Any())
throw new InvalidOperationException("Debe incluir al menos un ítem.");
if (formSeriesId <= 0)
throw new ArgumentOutOfRangeException(nameof(formSeriesId), "Serie inválida.");
// Reemplazo directo de la colección (más claro que Clear()+Add)
header.PhLsmExpeditionDetails = details.ToList();
var detailList = details.ToList();
ValidateNoDuplicateStockItems(detailList);
await ValidateSerializedConflictsAsync(detailList);
await ValidateStockAvailabilityAsync(detailList);
header.PhLsmExpeditionDetails = detailList;
return await _repo.CreateFullExpeditionAsync(header, formSeriesId);
}
private static void ValidateNoDuplicateStockItems(List<ELSExpeditionDetail> detailList)
{
var duplicateIds = detailList
.Where(d => d.StockitemId > 0)
.GroupBy(d => d.StockitemId)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.OrderBy(x => x)
.ToList();
if (duplicateIds.Count == 0)
return;
var msg = "No se puede emitir la expedición. " +
"El mismo StockItem fue seleccionado más de una vez: " +
string.Join(", ", duplicateIds);
throw new InvalidOperationException(msg);
}
private async Task ValidateSerializedConflictsAsync(List<ELSExpeditionDetail> detailList)
{
var requestedStockItemIds = detailList
.Where(d => d.StockitemId > 0)
.Select(d => d.StockitemId)
.Distinct()
.ToList();
if (requestedStockItemIds.Count == 0)
return;
var conflicts = await _repo.CheckStockItemConflictsAsync(
requestedStockItemIds,
ignoreExpeditionId: null);
if (conflicts.Count == 0)
return;
var lines = new List<string>
{
$"No se puede emitir la expedición: se detectaron {conflicts.Count} stock items serializados ya asignados a expediciones activas."
};
foreach (var conflict in conflicts
.OrderBy(x => x.StockitemId)
.ThenBy(x => x.Expeditionnumber))
{
var statusLabel = ((ExpeditionStatus)conflict.Status).ToLabel();
lines.Add($"• StockItem {conflict.StockitemId} → {conflict.Expeditionnumber} ({statusLabel})");
}
throw new InvalidOperationException(string.Join(Environment.NewLine, lines));
}
private async Task ValidateStockAvailabilityAsync(List<ELSExpeditionDetail> detailList)
{
var requestedByStockItem = detailList
.Where(d => d.StockitemId > 0 && d.Quantity > 0)
.GroupBy(d => d.StockitemId)
.Select(g => new
{
StockitemId = g.Key,
RequestedQuantity = g.Sum(x => x.Quantity)
})
.ToList();
if (requestedByStockItem.Count == 0)
return;
var availability = await _stockItemRepository.GetAvailabilityByStockItemIdsAsync(
requestedByStockItem.Select(x => x.StockitemId));
var availabilityMap = availability.ToDictionary(x => x.StockitemId);
var errors = new List<string>();
foreach (var request in requestedByStockItem.OrderBy(x => x.StockitemId))
{
if (!availabilityMap.TryGetValue(request.StockitemId, out var stock))
{
errors.Add($"• StockItem {request.StockitemId} → no fue encontrado en stock.");
continue;
}
var hasSerial = !string.IsNullOrWhiteSpace(stock.Serial);
// Los serializados ya se validan por exclusividad en ValidateSerializedConflictsAsync
if (hasSerial)
continue;
if (request.RequestedQuantity > stock.AvailableQuantity)
{
errors.Add(
$"• StockItem {request.StockitemId} → solicitado: {request.RequestedQuantity}, disponible: {stock.AvailableQuantity}.");
}
}
if (errors.Count == 0)
return;
var lines = new List<string>
{
"No se puede emitir la expedición: algunos stock items no serializados no tienen cantidad disponible suficiente."
};
lines.AddRange(errors);
throw new InvalidOperationException(string.Join(Environment.NewLine, lines));
}
#endregion
// Otros métodos de la clase...
public Task<ExpeditionDto?> GetDtoByExpeditionNumberAsync(string expeditionNumber)
{
throw new NotImplementedException();
}
#endregion
public Task<PagedResult<ExpeditionDto>> SearchAsync(
string? expeditionNumber,
string? status,
@ -49,7 +173,6 @@ namespace Core.Services.Stock
=> _repo.SearchAsync(expeditionNumber, status, issueDateFrom, issueDateTo, locationId, page, pageSize);
public Task<ExpeditionDto?> GetDtoByIdAsync(int id)
=> _repo.GetDtoByIdAsync(id);
public async Task<byte[]> ExportFilteredToExcelAsync(ExpeditionSearchParams searchParams)
{
try

View File

@ -0,0 +1,11 @@
namespace Domain.Dtos.Stock
{
public sealed class StockItemAvailabilityDto
{
public int StockitemId { get; set; }
public decimal Quantity { get; set; }
public decimal ReservedQuantity { get; set; }
public decimal AvailableQuantity { get; set; }
public string? Serial { get; set; }
}
}

View File

@ -0,0 +1,10 @@
namespace Domain.Dtos.Stock
{
public sealed class StockItemExpeditionConflictDto
{
public int StockitemId { get; set; }
public int ExpeditionId { get; set; }
public string Expeditionnumber { get; set; } = string.Empty;
public int Status { get; set; }
}
}

View File

@ -7,6 +7,16 @@ namespace Models.Interfaces
// 1.1 Data (Repo)
public interface IExpeditionRepository
{
/// <summary>
/// Verifica si alguno de los stock items indicados ya está asociado
/// a otra expedición activa. Utilizado para prevenir doble traza.
/// </summary>
/// <param name="stockItemIds">Lista de stockitem_id a validar.</param>
/// <param name="ignoreExpeditionId">
/// Expedición a ignorar (usado en edición para no detectar conflicto consigo misma).
/// </param>
/// <returns>Lista de conflictos encontrados.</returns>
Task<List<StockItemExpeditionConflictDto>> CheckStockItemConflictsAsync(IEnumerable<int> stockItemIds, int? ignoreExpeditionId);
/// <summary>
/// Crea la expedición completa (encabezado + detalles) y la deja emitida con numeración de serie.
/// </summary>

View File

@ -24,5 +24,9 @@ namespace Models.Interfaces
int? locationId,
int page,
int take);
// Obtener disponibilidad por IDs de StockItem
Task<List<StockItemAvailabilityDto>> GetAvailabilityByStockItemIdsAsync(
IEnumerable<int> stockItemIds);
}
}

View File

@ -2,10 +2,12 @@
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;
@ -56,7 +58,6 @@ namespace Models.Repositories.Stock
throw;
}
}
/// <summary>
/// Devuelve el DTO completo de Expedición (cabecera + ítems) listo para UI/impresión.
/// </summary>
@ -164,9 +165,7 @@ namespace Models.Repositories.Stock
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>
@ -310,6 +309,73 @@ namespace Models.Repositories.Stock
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;
@ -329,7 +395,6 @@ namespace Models.Repositories.Stock
_ => (int?)null
};
}
private static string NormalizeKey(string s)
{
var norm = s.Trim().ToLowerInvariant().Normalize(NormalizationForm.FormD);

View File

@ -1,9 +1,11 @@
using Domain.Dtos.Stock; // StockItemScanResultDto
using Microsoft.EntityFrameworkCore;
using Microsoft.Data.SqlClient;
using Domain.Dtos.Stock; // StockItemScanResultDto
using Domain.Generics; // PagedResult<T>
using Microsoft.EntityFrameworkCore;
using Models.Helpers; // ToPagedResultAsync
using Models.Interfaces; // IPhLSMStockItemRepository
using Models.Models; // PhronCareOperationsHubContext
using Models.Models;
using System.Data; // PhronCareOperationsHubContext
namespace Models.Repositories.Stock
{
@ -324,5 +326,74 @@ namespace Models.Repositories.Stock
PageSize = paged.PageSize
};
}
public async Task<List<StockItemAvailabilityDto>> GetAvailabilityByStockItemIdsAsync(
IEnumerable<int> stockItemIds)
{
try
{
var ids = stockItemIds?
.Where(x => x > 0)
.Distinct()
.ToList() ?? new List<int>();
if (ids.Count == 0)
return new List<StockItemAvailabilityDto>();
// TVP = Table Valued Parameter
var tvp = new DataTable();
tvp.Columns.Add("stockitem_id", typeof(int));
foreach (var id in ids)
tvp.Rows.Add(id);
var result = new List<StockItemAvailabilityDto>();
var conn = _context.Database.GetDbConnection();
var cs = _context.Database.GetConnectionString();
if (conn.State != ConnectionState.Open)
await _context.Database.OpenConnectionAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "dbo.PhLSM_Stock_GetAvailabilityByStockItemIds";
cmd.CommandType = CommandType.StoredProcedure;
var pIds = new SqlParameter("@StockItemIds", SqlDbType.Structured)
{
TypeName = "dbo.PhLSM_StockItemIdList",
Value = tvp
};
cmd.Parameters.Add(pIds);
await using var reader = await cmd.ExecuteReaderAsync();
var ordStockItemId = reader.GetOrdinal("StockitemId");
var ordQuantity = reader.GetOrdinal("Quantity");
var ordReserved = reader.GetOrdinal("ReservedQuantity");
var ordAvailable = reader.GetOrdinal("AvailableQuantity");
var ordSerial = reader.GetOrdinal("Serial");
while (await reader.ReadAsync())
{
result.Add(new StockItemAvailabilityDto
{
StockitemId = reader.GetInt32(ordStockItemId),
Quantity = reader.GetDecimal(ordQuantity),
ReservedQuantity = reader.GetDecimal(ordReserved),
AvailableQuantity = reader.GetDecimal(ordAvailable),
Serial = reader.IsDBNull(ordSerial)
? null
: reader.GetString(ordSerial)
});
}
return result;
}
catch (Exception ex)
{
throw new Exception(ex.ToString());
}
}
}
}

View File

@ -0,0 +1,44 @@
USE [phronCare_OperationsHub]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
-- =============================================
-- Procedure: PhLSM_Expedition_CheckStockItemConflicts
-- Module: Logistics / Stock (PhLSM)
-- Purpose: Detect serialized stock items already assigned
-- to active expeditions.
-- Author: Leandro Rojas
-- Created: 2026-03-05
-- =============================================
ALTER PROCEDURE [dbo].[PhLSM_Expedition_CheckStockItemConflicts]
(
@StockItemIds dbo.PhLSM_StockItemIdList READONLY,
@IgnoreExpeditionId INT = NULL
)
AS
BEGIN
SET NOCOUNT ON;
SELECT
d.stockitem_id AS StockitemId,
h.id AS ExpeditionId,
h.expeditionnumber AS Expeditionnumber,
h.status AS Status
FROM dbo.PhLSM_ExpeditionDetails d
INNER JOIN @StockItemIds ids
ON ids.stockitem_id = d.stockitem_id
INNER JOIN dbo.PhLSM_ExpeditionHeaders h
ON h.id = d.expedition_id
INNER JOIN dbo.PhLSM_StockItem si
ON si.id = d.stockitem_id
WHERE h.status NOT IN (5,6) -- 5=Cerrada, 6=Anulada
AND (@IgnoreExpeditionId IS NULL OR h.id <> @IgnoreExpeditionId)
AND si.serial IS NOT NULL
AND LTRIM(RTRIM(si.serial)) <> '';
END
GO

View File

@ -0,0 +1,5 @@
CREATE TYPE dbo.PhLSM_StockItemIdList AS TABLE
(
stockitem_id INT NOT NULL
);
GO

View File

@ -0,0 +1,36 @@
USE [phronCare_OperationsHub]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
-- =============================================
-- Procedure: PhLSM_Stock_GetAvailabilityByStockItemIds
-- Module: Logistics / Stock (PhLSM)
-- Purpose: Returns quantity and availability data
-- for the requested stock items.
-- Author: Leandro Rojas
-- Created: 2026-03-09
-- =============================================
CREATE OR ALTER PROCEDURE [dbo].[PhLSM_Stock_GetAvailabilityByStockItemIds]
(
@StockItemIds dbo.PhLSM_StockItemIdList READONLY
)
AS
BEGIN
SET NOCOUNT ON;
SELECT
si.id AS StockitemId,
si.quantity AS Quantity,
ISNULL(si.reserved_quantity, 0) AS ReservedQuantity,
si.quantity - ISNULL(si.reserved_quantity, 0) AS AvailableQuantity,
si.serial AS Serial
FROM dbo.PhLSM_StockItem si
INNER JOIN @StockItemIds ids
ON ids.stockitem_id = si.id;
END
GO

View File

@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.6.33815.320
# Visual Studio Version 18
VisualStudioVersion = 18.3.11520.95
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "phronCare.API", "phronCare.API\phronCare.API.csproj", "{13FF5898-C6D4-42AE-AC36-8D42C88FEDAF}"
EndProject
@ -43,6 +43,21 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1.5 Documents", "1.5 Docume
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Documents", "Documents\Documents.csproj", "{0EFF27D3-C585-49F3-BBB5-A5E99C52207B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2.Database", "2.Database", "{E93C8350-6A6C-40F1-99C0-14CF7459EA8B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migrations", "Migrations", "{5826BD19-0018-4F9C-B405-9BAB997CC8C7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Types", "Types", "{3C3276F6-7320-4AB8-9C1E-1893E4646FD4}"
ProjectSection(SolutionItems) = preProject
PhLSM_StockItemIdList.sql = PhLSM_StockItemIdList.sql
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Procedures", "Procedures", "{E4F7BA13-B838-42D5-AADD-481DD76DDD5E}"
ProjectSection(SolutionItems) = preProject
PhLSM_Expedition_CheckStockItemConflicts.sql = PhLSM_Expedition_CheckStockItemConflicts.sql
PhLSM_Stock_GetAvailabilityByStockItemIds.sql = PhLSM_Stock_GetAvailabilityByStockItemIds.sql
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -104,6 +119,9 @@ Global
{34FC5538-4779-41F5-8355-7866B1395A4F} = {13328F60-28A6-446D-9935-23866F3F3D9D}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {74280C0B-F2CC-4A3E-86D6-05530F9766D5}
{0EFF27D3-C585-49F3-BBB5-A5E99C52207B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{5826BD19-0018-4F9C-B405-9BAB997CC8C7} = {E93C8350-6A6C-40F1-99C0-14CF7459EA8B}
{3C3276F6-7320-4AB8-9C1E-1893E4646FD4} = {E93C8350-6A6C-40F1-99C0-14CF7459EA8B}
{E4F7BA13-B838-42D5-AADD-481DD76DDD5E} = {E93C8350-6A6C-40F1-99C0-14CF7459EA8B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {92DE3FEB-7D7E-4C78-BE8C-34931CA1DAED}