Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.
Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.
Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
shadow xmin загруженного supply через EF.Entry().Property("xmin").
Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
защита: и явная сверка, и EF auto-check в SaveChanges).
Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).
Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
112 lines
5.3 KiB
C#
112 lines
5.3 KiB
C#
using foodmarket.Domain.Purchases;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace foodmarket.Infrastructure.Persistence.Configurations;
|
|
|
|
public static class PurchasesConfigurations
|
|
{
|
|
public static void ConfigurePurchases(this ModelBuilder b)
|
|
{
|
|
b.Entity<Supply>(e =>
|
|
{
|
|
e.ToTable("supplies");
|
|
// Optimistic concurrency через Postgres xmin (Npgsql-native):
|
|
// UseXminAsConcurrencyToken создаёт shadow-property "xmin" — EF
|
|
// её читает в SELECT и сверяет в WHERE при UPDATE. Доступ из C#
|
|
// через EF.Property<uint>(supply, "xmin") (см. GetInternal).
|
|
// Свою .NET-property Xmin игнорируем — она существует только для
|
|
// транспорта в DTO (контроллер заполняет вручную).
|
|
e.UseXminAsConcurrencyToken();
|
|
e.Ignore(x => x.Xmin);
|
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
|
|
|
e.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
|
|
|
|
e.HasMany(x => x.Lines).WithOne(l => l.Supply).HasForeignKey(l => l.SupplyId).OnDelete(DeleteBehavior.Cascade);
|
|
|
|
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
|
|
e.HasIndex(x => new { x.OrganizationId, x.Date });
|
|
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
|
e.HasIndex(x => new { x.OrganizationId, x.SupplierId });
|
|
});
|
|
|
|
b.Entity<SupplyLine>(e =>
|
|
{
|
|
e.ToTable("supply_lines");
|
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
|
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
|
e.Property(x => x.LineTotal).HasPrecision(18, 4);
|
|
|
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
|
|
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
|
});
|
|
|
|
b.Entity<Enter>(e =>
|
|
{
|
|
e.ToTable("enters");
|
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
|
|
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
|
|
|
|
e.HasMany(x => x.Lines).WithOne(l => l.Enter).HasForeignKey(l => l.EnterId).OnDelete(DeleteBehavior.Cascade);
|
|
|
|
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
|
|
e.HasIndex(x => new { x.OrganizationId, x.Date });
|
|
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
|
});
|
|
|
|
b.Entity<EnterLine>(e =>
|
|
{
|
|
e.ToTable("enter_lines");
|
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
|
e.Property(x => x.UnitCost).HasPrecision(18, 4);
|
|
e.Property(x => x.LineTotal).HasPrecision(18, 4);
|
|
|
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
|
|
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
|
});
|
|
|
|
b.Entity<SupplierReturn>(e =>
|
|
{
|
|
e.ToTable("supplier_returns");
|
|
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
|
e.Property(x => x.Notes).HasMaxLength(1000);
|
|
e.Property(x => x.Total).HasPrecision(18, 4);
|
|
|
|
e.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(x => x.ReferenceSupply).WithMany().HasForeignKey(x => x.ReferenceSupplyId).OnDelete(DeleteBehavior.Restrict);
|
|
|
|
e.HasMany(x => x.Lines).WithOne(l => l.SupplierReturn).HasForeignKey(l => l.SupplierReturnId).OnDelete(DeleteBehavior.Cascade);
|
|
|
|
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
|
|
e.HasIndex(x => new { x.OrganizationId, x.Date });
|
|
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
|
e.HasIndex(x => new { x.OrganizationId, x.SupplierId });
|
|
e.HasIndex(x => x.ReferenceSupplyId);
|
|
});
|
|
|
|
b.Entity<SupplierReturnLine>(e =>
|
|
{
|
|
e.ToTable("supplier_return_lines");
|
|
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
|
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
|
e.Property(x => x.LineTotal).HasPrecision(18, 4);
|
|
|
|
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
|
|
|
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
|
});
|
|
}
|
|
}
|