From 394c864dfa38eba5f0c198b107ed21dd4d65abcb Mon Sep 17 00:00:00 2001 From: leandro Date: Mon, 2 Mar 2026 19:44:49 -0300 Subject: [PATCH] ffeat(expeditions): persist stockitem_id in ExpeditionDetails (traceability base) - Added stockitem_id column to PhLSM_ExpeditionDetails - Added FK to PhLSM_StockItem - Added indexes (StockItem and Expedition_StockItem) - Updated scaffold models - Updated UI merge to preserve StockItemId - CreateFullExpeditionAsync now persists stockitem_id - Base step to enable logistic states and double-trace prevention Closes #3 --- Domain/Dtos/Stock/StockSnapshotItem.cs | 1 + Domain/Entities/ELSExpeditionDetail.cs | 5 + Models/Models/PhLsmExpeditionDetail.cs | 7 ++ Models/Models/PhLsmStockItem.cs | 4 + Models/Models/PhLsmStockReservation.cs | 5 +- .../Models/PhronCareOperationsHubContext.cs | 93 ++++++++++++++++--- .../Stock/Expeditions/ExpeditionCreate.razor | 10 +- .../Stock/Shared/StockItemSelectorModal.razor | 1 + 8 files changed, 109 insertions(+), 17 deletions(-) diff --git a/Domain/Dtos/Stock/StockSnapshotItem.cs b/Domain/Dtos/Stock/StockSnapshotItem.cs index ed52659..069f64a 100644 --- a/Domain/Dtos/Stock/StockSnapshotItem.cs +++ b/Domain/Dtos/Stock/StockSnapshotItem.cs @@ -7,6 +7,7 @@ public class StockSnapshotItem { public int ProductId { get; set; } + public int StockitemId { get; set; } public string? ProductName { get; set; } = string.Empty; public int LocationId { get; set; } public string Batch { get; set; } = string.Empty; diff --git a/Domain/Entities/ELSExpeditionDetail.cs b/Domain/Entities/ELSExpeditionDetail.cs index 5f0a530..4182716 100644 --- a/Domain/Entities/ELSExpeditionDetail.cs +++ b/Domain/Entities/ELSExpeditionDetail.cs @@ -17,6 +17,11 @@ /// public int ProductId { get; set; } + /// + /// Referencia a StockItem (PhLSM_StockItem) + /// + public int StockitemId { get; set; } + /// /// Cantidad solicitada del producto /// diff --git a/Models/Models/PhLsmExpeditionDetail.cs b/Models/Models/PhLsmExpeditionDetail.cs index eb5c16b..1eaa77d 100644 --- a/Models/Models/PhLsmExpeditionDetail.cs +++ b/Models/Models/PhLsmExpeditionDetail.cs @@ -20,6 +20,11 @@ public partial class PhLsmExpeditionDetail /// public int ProductId { get; set; } + /// + /// Referencia a StockItem (PhLSM_StockItem) + /// + public int StockitemId { get; set; } + /// /// Cantidad solicitada del producto /// @@ -68,4 +73,6 @@ public partial class PhLsmExpeditionDetail public virtual PhLsmExpeditionHeader Expedition { get; set; } = null!; public virtual PhLsmProduct Product { get; set; } = null!; + + public virtual PhLsmStockItem Stockitem { get; set; } = null!; } diff --git a/Models/Models/PhLsmStockItem.cs b/Models/Models/PhLsmStockItem.cs index bbc9f60..c8e9330 100644 --- a/Models/Models/PhLsmStockItem.cs +++ b/Models/Models/PhLsmStockItem.cs @@ -67,5 +67,9 @@ public partial class PhLsmStockItem public virtual PhLsmStockLocation Location { get; set; } = null!; + public virtual ICollection PhLsmExpeditionDetails { get; set; } = new List(); + + public virtual ICollection PhLsmStockReservations { get; set; } = new List(); + public virtual PhLsmProduct Product { get; set; } = null!; } diff --git a/Models/Models/PhLsmStockReservation.cs b/Models/Models/PhLsmStockReservation.cs index 039f47a..fb86da2 100644 --- a/Models/Models/PhLsmStockReservation.cs +++ b/Models/Models/PhLsmStockReservation.cs @@ -1,4 +1,7 @@ -namespace Models.Models; +using System; +using System.Collections.Generic; + +namespace Models.Models; /// /// Reservas de stock por origen genérico (source_type/source_id). Cada fila bloquea cantidad sobre un StockItem. No duplica lote/serie/vencimiento; se resuelve por JOIN a PhLSM_StockItem. diff --git a/Models/Models/PhronCareOperationsHubContext.cs b/Models/Models/PhronCareOperationsHubContext.cs index d7a8df1..41c901a 100644 --- a/Models/Models/PhronCareOperationsHubContext.cs +++ b/Models/Models/PhronCareOperationsHubContext.cs @@ -6,10 +6,6 @@ namespace Models.Models; public partial class PhronCareOperationsHubContext : DbContext { - public PhronCareOperationsHubContext() - { - } - public PhronCareOperationsHubContext(DbContextOptions options) : base(options) { @@ -35,6 +31,8 @@ public partial class PhronCareOperationsHubContext : DbContext public virtual DbSet PhLsmStockOuts { get; set; } + public virtual DbSet PhLsmStockReservations { get; set; } + public virtual DbSet PhLsmUnitOfMeasures { get; set; } public virtual DbSet PhOhArcadocumentTypes { get; set; } @@ -95,17 +93,6 @@ public partial class PhronCareOperationsHubContext : DbContext public virtual DbSet PhSQuoteTaxes { get; set; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - #region VERSION DOCKER - { - if (!optionsBuilder.IsConfigured) - { - // Dejarlo vacío para usar la configuración externa desde Program.cs o Startup.cs - } - } - #endregion - //=> optionsBuilder.UseSqlServer("data source=srv01.saludlab.com.ar,39458;initial catalog=phronCare_OperationsHub;User ID=sa;Password=HS|s[~xxQzTo/n>9jO;encrypt=False;trustServerCertificate=True;MultipleActiveResultSets=True"); - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.UseCollation("Modern_Spanish_CI_AS"); @@ -116,6 +103,14 @@ public partial class PhronCareOperationsHubContext : DbContext entity.ToTable("PhLSM_ExpeditionDetails"); + entity.HasIndex(e => e.ExpeditionId, "IX_PhLSM_ExpeditionDetails_Expedition"); + + entity.HasIndex(e => new { e.ExpeditionId, e.StockitemId }, "IX_PhLSM_ExpeditionDetails_Expedition_StockItem"); + + entity.HasIndex(e => e.ProductId, "IX_PhLSM_ExpeditionDetails_Product"); + + entity.HasIndex(e => e.StockitemId, "IX_PhLSM_ExpeditionDetails_StockItem"); + entity.Property(e => e.Id) .HasComment("Identificador interno del ítem de expedición") .HasColumnName("id"); @@ -162,6 +157,9 @@ public partial class PhronCareOperationsHubContext : DbContext .HasMaxLength(100) .HasComment("Número de serie de la unidad individual, según etiqueta de trazabilidad del fabricante.") .HasColumnName("serial"); + entity.Property(e => e.StockitemId) + .HasComment("Referencia a StockItem (PhLSM_StockItem)") + .HasColumnName("stockitem_id"); entity.HasOne(d => d.Expedition).WithMany(p => p.PhLsmExpeditionDetails) .HasForeignKey(d => d.ExpeditionId) @@ -172,6 +170,11 @@ public partial class PhronCareOperationsHubContext : DbContext .HasForeignKey(d => d.ProductId) .OnDelete(DeleteBehavior.ClientSetNull) .HasConstraintName("FK_PhLSM_ExpeditionDetails_PhLSM_Product"); + + entity.HasOne(d => d.Stockitem).WithMany(p => p.PhLsmExpeditionDetails) + .HasForeignKey(d => d.StockitemId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_PhLSM_ExpeditionDetails_PhLSM_StockItem"); }); modelBuilder.Entity(entity => @@ -180,6 +183,8 @@ public partial class PhronCareOperationsHubContext : DbContext entity.ToTable("PhLSM_ExpeditionHeaders"); + entity.HasIndex(e => e.Expeditionnumber, "UX_PhLSM_ExpeditionHeaders_Number").IsUnique(); + entity.Property(e => e.Id) .HasComment("Identificador interno de la expedición") .HasColumnName("id"); @@ -641,6 +646,64 @@ public partial class PhronCareOperationsHubContext : DbContext .HasConstraintName("FK_PhLSM_StockOut_PhLSM_Product"); }); + modelBuilder.Entity(entity => + { + entity.ToTable("PhLSM_StockReservation", tb => tb.HasComment("Reservas de stock por origen genérico (source_type/source_id). Cada fila bloquea cantidad sobre un StockItem. No duplica lote/serie/vencimiento; se resuelve por JOIN a PhLSM_StockItem.")); + + entity.HasIndex(e => new { e.SourceType, e.SourceId, e.Status }, "IX_PhLSM_StockReservation_Source_Status"); + + entity.HasIndex(e => e.StockitemId, "IX_PhLSM_StockReservation_StockItem_Reserved").HasFilter("([status]=(1))"); + + entity.HasIndex(e => new { e.SourceType, e.SourceId, e.StockitemId }, "UX_PhLSM_StockReservation_Source_StockItem_Consumed") + .IsUnique() + .HasFilter("([status]=(3))"); + + entity.HasIndex(e => new { e.SourceType, e.SourceId, e.StockitemId }, "UX_PhLSM_StockReservation_Source_StockItem_Reserved") + .IsUnique() + .HasFilter("([status]=(1))"); + + entity.Property(e => e.Id) + .HasComment("Identificador autoincremental de la reserva.") + .HasColumnName("id"); + entity.Property(e => e.Createdat) + .HasPrecision(0) + .HasDefaultValueSql("(sysutcdatetime())") + .HasComment("Fecha/hora de creación (UTC).") + .HasColumnName("createdat"); + entity.Property(e => e.Modifiedat) + .HasPrecision(0) + .HasComment("Última modificación (UTC). Puede ser NULL si nunca se actualizó.") + .HasColumnName("modifiedat"); + entity.Property(e => e.ReservedQuantity) + .HasComment("Cantidad reservada (bloqueada). No disponible mientras status=1 (Reserved).") + .HasColumnType("decimal(18, 2)") + .HasColumnName("reserved_quantity"); + entity.Property(e => e.Rowversion) + .IsRowVersion() + .IsConcurrencyToken() + .HasComment("Token de concurrencia optimista (ROWVERSION) para actualizaciones seguras.") + .HasColumnName("rowversion"); + entity.Property(e => e.SourceId) + .HasComment("Identificador del origen. Ej.: expedition_id cuando source_type=1.") + .HasColumnName("source_id"); + entity.Property(e => e.SourceType) + .HasDefaultValue((byte)1) + .HasComment("Tipo de origen de la reserva. 1=Expedition (extensible a futuros orígenes).") + .HasColumnName("source_type"); + entity.Property(e => e.Status) + .HasDefaultValue(1) + .HasComment("Estado de la reserva: 1=Reserved, 2=Released, 3=Consumed.") + .HasColumnName("status"); + entity.Property(e => e.StockitemId) + .HasComment("Referencia al StockItem exacto bloqueado (FK a PhLSM_StockItem). Define producto/ubicación/trazabilidad por JOIN.") + .HasColumnName("stockitem_id"); + + entity.HasOne(d => d.Stockitem).WithMany(p => p.PhLsmStockReservations) + .HasForeignKey(d => d.StockitemId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("FK_PhLSM_StockReservation_StockItem"); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id).HasName("PK__PhLSM_Un__3213E83FD70349B6"); diff --git a/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor b/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor index 494ff91..d568f3b 100644 --- a/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor +++ b/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor @@ -360,6 +360,12 @@ existing.Expiration = exp; existing.LocationId = s.LocationId; existing.TraceabilityType = s.TraceabilityType; // UI only + + // 🆕 AGREGAR ESTO + if (s.StockItemId != 0 && existing.StockitemId != s.StockItemId) + { + existing.StockitemId = s.StockItemId; + } } else { @@ -374,7 +380,8 @@ Expiration = exp, TraceabilityType = s.TraceabilityType, // UI only (no DB) Serial = s.Serial, - LocationId = s.LocationId + LocationId = s.LocationId, + StockitemId = s.StockItemId }); } // si newQty == 0 y no existía, no hacemos nada @@ -397,6 +404,7 @@ return new StockSnapshotItem { ProductId = d.ProductId, + StockitemId = d.StockitemId, // 🆕 incluir StockitemId en el snapshot ProductName = d.ProductName, LocationId = d.LocationId, Batch = d.Batch ?? string.Empty, diff --git a/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor b/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor index b918372..1f07d57 100644 --- a/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor +++ b/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor @@ -114,6 +114,7 @@ StockList = Snapshot.Select(s => new StockDisplayRow { ProductId = s.ProductId, + StockItemId = s.StockitemId, ProductName = s.ProductName ?? "", Batch = s.Batch, Serial = s.Serial,