ffeat(expeditions): persist stockitem_id in ExpeditionDetails (traceability base)
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 6m37s

- 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
This commit is contained in:
Leandro Hernan Rojas 2026-03-02 19:44:49 -03:00
parent 6e22969787
commit 394c864dfa
8 changed files with 109 additions and 17 deletions

View File

@ -7,6 +7,7 @@
public class StockSnapshotItem public class StockSnapshotItem
{ {
public int ProductId { get; set; } public int ProductId { get; set; }
public int StockitemId { get; set; }
public string? ProductName { get; set; } = string.Empty; public string? ProductName { get; set; } = string.Empty;
public int LocationId { get; set; } public int LocationId { get; set; }
public string Batch { get; set; } = string.Empty; public string Batch { get; set; } = string.Empty;

View File

@ -17,6 +17,11 @@
/// </summary> /// </summary>
public int ProductId { get; set; } public int ProductId { get; set; }
/// <summary>
/// Referencia a StockItem (PhLSM_StockItem)
/// </summary>
public int StockitemId { get; set; }
/// <summary> /// <summary>
/// Cantidad solicitada del producto /// Cantidad solicitada del producto
/// </summary> /// </summary>

View File

@ -20,6 +20,11 @@ public partial class PhLsmExpeditionDetail
/// </summary> /// </summary>
public int ProductId { get; set; } public int ProductId { get; set; }
/// <summary>
/// Referencia a StockItem (PhLSM_StockItem)
/// </summary>
public int StockitemId { get; set; }
/// <summary> /// <summary>
/// Cantidad solicitada del producto /// Cantidad solicitada del producto
/// </summary> /// </summary>
@ -68,4 +73,6 @@ public partial class PhLsmExpeditionDetail
public virtual PhLsmExpeditionHeader Expedition { get; set; } = null!; public virtual PhLsmExpeditionHeader Expedition { get; set; } = null!;
public virtual PhLsmProduct Product { get; set; } = null!; public virtual PhLsmProduct Product { get; set; } = null!;
public virtual PhLsmStockItem Stockitem { get; set; } = null!;
} }

View File

@ -67,5 +67,9 @@ public partial class PhLsmStockItem
public virtual PhLsmStockLocation Location { get; set; } = null!; public virtual PhLsmStockLocation Location { get; set; } = null!;
public virtual ICollection<PhLsmExpeditionDetail> PhLsmExpeditionDetails { get; set; } = new List<PhLsmExpeditionDetail>();
public virtual ICollection<PhLsmStockReservation> PhLsmStockReservations { get; set; } = new List<PhLsmStockReservation>();
public virtual PhLsmProduct Product { get; set; } = null!; public virtual PhLsmProduct Product { get; set; } = null!;
} }

View File

@ -1,4 +1,7 @@
namespace Models.Models; using System;
using System.Collections.Generic;
namespace Models.Models;
/// <summary> /// <summary>
/// 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. /// 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.

View File

@ -6,10 +6,6 @@ namespace Models.Models;
public partial class PhronCareOperationsHubContext : DbContext public partial class PhronCareOperationsHubContext : DbContext
{ {
public PhronCareOperationsHubContext()
{
}
public PhronCareOperationsHubContext(DbContextOptions<PhronCareOperationsHubContext> options) public PhronCareOperationsHubContext(DbContextOptions<PhronCareOperationsHubContext> options)
: base(options) : base(options)
{ {
@ -35,6 +31,8 @@ public partial class PhronCareOperationsHubContext : DbContext
public virtual DbSet<PhLsmStockOut> PhLsmStockOuts { get; set; } public virtual DbSet<PhLsmStockOut> PhLsmStockOuts { get; set; }
public virtual DbSet<PhLsmStockReservation> PhLsmStockReservations { get; set; }
public virtual DbSet<PhLsmUnitOfMeasure> PhLsmUnitOfMeasures { get; set; } public virtual DbSet<PhLsmUnitOfMeasure> PhLsmUnitOfMeasures { get; set; }
public virtual DbSet<PhOhArcadocumentType> PhOhArcadocumentTypes { get; set; } public virtual DbSet<PhOhArcadocumentType> PhOhArcadocumentTypes { get; set; }
@ -95,17 +93,6 @@ public partial class PhronCareOperationsHubContext : DbContext
public virtual DbSet<PhSQuoteTaxis> PhSQuoteTaxes { get; set; } public virtual DbSet<PhSQuoteTaxis> 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.UseCollation("Modern_Spanish_CI_AS"); modelBuilder.UseCollation("Modern_Spanish_CI_AS");
@ -116,6 +103,14 @@ public partial class PhronCareOperationsHubContext : DbContext
entity.ToTable("PhLSM_ExpeditionDetails"); 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) entity.Property(e => e.Id)
.HasComment("Identificador interno del ítem de expedición") .HasComment("Identificador interno del ítem de expedición")
.HasColumnName("id"); .HasColumnName("id");
@ -162,6 +157,9 @@ public partial class PhronCareOperationsHubContext : DbContext
.HasMaxLength(100) .HasMaxLength(100)
.HasComment("Número de serie de la unidad individual, según etiqueta de trazabilidad del fabricante.") .HasComment("Número de serie de la unidad individual, según etiqueta de trazabilidad del fabricante.")
.HasColumnName("serial"); .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) entity.HasOne(d => d.Expedition).WithMany(p => p.PhLsmExpeditionDetails)
.HasForeignKey(d => d.ExpeditionId) .HasForeignKey(d => d.ExpeditionId)
@ -172,6 +170,11 @@ public partial class PhronCareOperationsHubContext : DbContext
.HasForeignKey(d => d.ProductId) .HasForeignKey(d => d.ProductId)
.OnDelete(DeleteBehavior.ClientSetNull) .OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("FK_PhLSM_ExpeditionDetails_PhLSM_Product"); .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<PhLsmExpeditionHeader>(entity => modelBuilder.Entity<PhLsmExpeditionHeader>(entity =>
@ -180,6 +183,8 @@ public partial class PhronCareOperationsHubContext : DbContext
entity.ToTable("PhLSM_ExpeditionHeaders"); entity.ToTable("PhLSM_ExpeditionHeaders");
entity.HasIndex(e => e.Expeditionnumber, "UX_PhLSM_ExpeditionHeaders_Number").IsUnique();
entity.Property(e => e.Id) entity.Property(e => e.Id)
.HasComment("Identificador interno de la expedición") .HasComment("Identificador interno de la expedición")
.HasColumnName("id"); .HasColumnName("id");
@ -641,6 +646,64 @@ public partial class PhronCareOperationsHubContext : DbContext
.HasConstraintName("FK_PhLSM_StockOut_PhLSM_Product"); .HasConstraintName("FK_PhLSM_StockOut_PhLSM_Product");
}); });
modelBuilder.Entity<PhLsmStockReservation>(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<PhLsmUnitOfMeasure>(entity => modelBuilder.Entity<PhLsmUnitOfMeasure>(entity =>
{ {
entity.HasKey(e => e.Id).HasName("PK__PhLSM_Un__3213E83FD70349B6"); entity.HasKey(e => e.Id).HasName("PK__PhLSM_Un__3213E83FD70349B6");

View File

@ -360,6 +360,12 @@
existing.Expiration = exp; existing.Expiration = exp;
existing.LocationId = s.LocationId; existing.LocationId = s.LocationId;
existing.TraceabilityType = s.TraceabilityType; // UI only existing.TraceabilityType = s.TraceabilityType; // UI only
// 🆕 AGREGAR ESTO
if (s.StockItemId != 0 && existing.StockitemId != s.StockItemId)
{
existing.StockitemId = s.StockItemId;
}
} }
else else
{ {
@ -374,7 +380,8 @@
Expiration = exp, Expiration = exp,
TraceabilityType = s.TraceabilityType, // UI only (no DB) TraceabilityType = s.TraceabilityType, // UI only (no DB)
Serial = s.Serial, Serial = s.Serial,
LocationId = s.LocationId LocationId = s.LocationId,
StockitemId = s.StockItemId
}); });
} }
// si newQty == 0 y no existía, no hacemos nada // si newQty == 0 y no existía, no hacemos nada
@ -397,6 +404,7 @@
return new StockSnapshotItem return new StockSnapshotItem
{ {
ProductId = d.ProductId, ProductId = d.ProductId,
StockitemId = d.StockitemId, // 🆕 incluir StockitemId en el snapshot
ProductName = d.ProductName, ProductName = d.ProductName,
LocationId = d.LocationId, LocationId = d.LocationId,
Batch = d.Batch ?? string.Empty, Batch = d.Batch ?? string.Empty,

View File

@ -114,6 +114,7 @@
StockList = Snapshot.Select(s => new StockDisplayRow StockList = Snapshot.Select(s => new StockDisplayRow
{ {
ProductId = s.ProductId, ProductId = s.ProductId,
StockItemId = s.StockitemId,
ProductName = s.ProductName ?? "<Producto sin descripción>", ProductName = s.ProductName ?? "<Producto sin descripción>",
Batch = s.Batch, Batch = s.Batch,
Serial = s.Serial, Serial = s.Serial,