From 6d0a72c01d773e37d7bf2d6f6c421d3f0545ad15 Mon Sep 17 00:00:00 2001 From: leandro Date: Wed, 11 Mar 2026 23:35:51 -0300 Subject: [PATCH] feat(expeditions): prevent reuse of stockitem_id in active expeditions Closes #5 --- Core/Services/Stock/ExpeditionService.cs | 137 +++++++++++++++++- Domain/Dtos/Stock/StockItemAvailabilityDto.cs | 11 ++ .../Stock/StockItemExpeditionConflictDto.cs | 10 ++ Models/Interfaces/IExpeditionRepository.cs | 10 ++ .../Interfaces/IPhLSMStockItemRepository.cs | 4 + .../Stock/PhLSMExpeditionRepository.cs | 73 +++++++++- .../Stock/PhLSMStockItemRepository.cs | 77 +++++++++- PhLSM_Expedition_CheckStockItemConflicts.sql | 44 ++++++ PhLSM_StockItemIdList.sql | 5 + PhLSM_Stock_GetAvailabilityByStockItemIds.sql | 36 +++++ phronCare.sln | 22 ++- 11 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 Domain/Dtos/Stock/StockItemAvailabilityDto.cs create mode 100644 Domain/Dtos/Stock/StockItemExpeditionConflictDto.cs create mode 100644 PhLSM_Expedition_CheckStockItemConflicts.sql create mode 100644 PhLSM_StockItemIdList.sql create mode 100644 PhLSM_Stock_GetAvailabilityByStockItemIds.sql diff --git a/Core/Services/Stock/ExpeditionService.cs b/Core/Services/Stock/ExpeditionService.cs index 3769e81..85708be 100644 --- a/Core/Services/Stock/ExpeditionService.cs +++ b/Core/Services/Stock/ExpeditionService.cs @@ -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 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 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 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 + { + $"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 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(); + + 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 + { + "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 GetDtoByExpeditionNumberAsync(string expeditionNumber) { throw new NotImplementedException(); } - #endregion - public Task> SearchAsync( string? expeditionNumber, string? status, @@ -49,7 +173,6 @@ namespace Core.Services.Stock => _repo.SearchAsync(expeditionNumber, status, issueDateFrom, issueDateTo, locationId, page, pageSize); public Task GetDtoByIdAsync(int id) => _repo.GetDtoByIdAsync(id); - public async Task ExportFilteredToExcelAsync(ExpeditionSearchParams searchParams) { try diff --git a/Domain/Dtos/Stock/StockItemAvailabilityDto.cs b/Domain/Dtos/Stock/StockItemAvailabilityDto.cs new file mode 100644 index 0000000..cf1453b --- /dev/null +++ b/Domain/Dtos/Stock/StockItemAvailabilityDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Domain/Dtos/Stock/StockItemExpeditionConflictDto.cs b/Domain/Dtos/Stock/StockItemExpeditionConflictDto.cs new file mode 100644 index 0000000..4c8a026 --- /dev/null +++ b/Domain/Dtos/Stock/StockItemExpeditionConflictDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Models/Interfaces/IExpeditionRepository.cs b/Models/Interfaces/IExpeditionRepository.cs index 3975de9..5270ce2 100644 --- a/Models/Interfaces/IExpeditionRepository.cs +++ b/Models/Interfaces/IExpeditionRepository.cs @@ -7,6 +7,16 @@ namespace Models.Interfaces // 1.1 Data (Repo) public interface IExpeditionRepository { + /// + /// Verifica si alguno de los stock items indicados ya está asociado + /// a otra expedición activa. Utilizado para prevenir doble traza. + /// + /// Lista de stockitem_id a validar. + /// + /// Expedición a ignorar (usado en edición para no detectar conflicto consigo misma). + /// + /// Lista de conflictos encontrados. + Task> CheckStockItemConflictsAsync(IEnumerable stockItemIds, int? ignoreExpeditionId); /// /// Crea la expedición completa (encabezado + detalles) y la deja emitida con numeración de serie. /// diff --git a/Models/Interfaces/IPhLSMStockItemRepository.cs b/Models/Interfaces/IPhLSMStockItemRepository.cs index 0842e30..be68a01 100644 --- a/Models/Interfaces/IPhLSMStockItemRepository.cs +++ b/Models/Interfaces/IPhLSMStockItemRepository.cs @@ -24,5 +24,9 @@ namespace Models.Interfaces int? locationId, int page, int take); + + // Obtener disponibilidad por IDs de StockItem + Task> GetAvailabilityByStockItemIdsAsync( + IEnumerable stockItemIds); } } diff --git a/Models/Repositories/Stock/PhLSMExpeditionRepository.cs b/Models/Repositories/Stock/PhLSMExpeditionRepository.cs index c0d6651..a58835b 100644 --- a/Models/Repositories/Stock/PhLSMExpeditionRepository.cs +++ b/Models/Repositories/Stock/PhLSMExpeditionRepository.cs @@ -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; } } - /// /// Devuelve el DTO completo de Expedición (cabecera + ítems) listo para UI/impresión. /// @@ -164,9 +165,7 @@ namespace Models.Repositories.Stock return dto; } - // ----- helpers ----- - /// /// Mapea el estado entero a etiqueta amigable (enum: Emitida=1, EnTransito=2, EnDestino=3, Retorno=4, Cerrada=5, Anulada=6). /// @@ -310,6 +309,73 @@ namespace Models.Repositories.Stock PageSize = pageSize }; } + public async Task> CheckStockItemConflictsAsync( + IEnumerable stockItemIds, + int? ignoreExpeditionId) + { + // Normalización defensiva + var ids = (stockItemIds ?? Enumerable.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return new List(); + + // 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(); + + // 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); diff --git a/Models/Repositories/Stock/PhLSMStockItemRepository.cs b/Models/Repositories/Stock/PhLSMStockItemRepository.cs index 5dbf8f5..0f40914 100644 --- a/Models/Repositories/Stock/PhLSMStockItemRepository.cs +++ b/Models/Repositories/Stock/PhLSMStockItemRepository.cs @@ -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 -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> GetAvailabilityByStockItemIdsAsync( + IEnumerable stockItemIds) + { + try + { + var ids = stockItemIds? + .Where(x => x > 0) + .Distinct() + .ToList() ?? new List(); + + if (ids.Count == 0) + return new List(); + + // 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(); + 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()); + } + } } } diff --git a/PhLSM_Expedition_CheckStockItemConflicts.sql b/PhLSM_Expedition_CheckStockItemConflicts.sql new file mode 100644 index 0000000..b265783 --- /dev/null +++ b/PhLSM_Expedition_CheckStockItemConflicts.sql @@ -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 \ No newline at end of file diff --git a/PhLSM_StockItemIdList.sql b/PhLSM_StockItemIdList.sql new file mode 100644 index 0000000..66d899c --- /dev/null +++ b/PhLSM_StockItemIdList.sql @@ -0,0 +1,5 @@ +CREATE TYPE dbo.PhLSM_StockItemIdList AS TABLE +( + stockitem_id INT NOT NULL +); +GO \ No newline at end of file diff --git a/PhLSM_Stock_GetAvailabilityByStockItemIds.sql b/PhLSM_Stock_GetAvailabilityByStockItemIds.sql new file mode 100644 index 0000000..0a36140 --- /dev/null +++ b/PhLSM_Stock_GetAvailabilityByStockItemIds.sql @@ -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 \ No newline at end of file diff --git a/phronCare.sln b/phronCare.sln index bcd38da..f088fa4 100644 --- a/phronCare.sln +++ b/phronCare.sln @@ -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} -- 2.47.1