adding complete db migrations from posgres

This commit is contained in:
2026-10-05 15:57:55 +05:30
parent 9d710349c2
commit 65f58c4000
113 changed files with 24033 additions and 1007 deletions
@@ -78,7 +78,11 @@ public static class AuditScribe
foreach (var p in entry.Properties)
{
if (p.Metadata.IsPrimaryKey()) continue;
if (p.Metadata.Name == nameof(Item.RowVersion)) continue;
// Semantic rather than name-matched: covers every concurrency token automatically
// and survives a rename. (This used to be `Name == nameof(Item.RowVersion)`, which
// silently missed BundleSale/BundleSaleTemplate's differently-named token and so
// leaked it into every bundle-sale change set.)
if (p.Metadata.IsConcurrencyToken) continue;
switch (action)
{
@@ -22,8 +22,6 @@ public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration<Att
builder.HasOne(r => r.WorkShift).WithMany()
.HasForeignKey(r => r.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.EmployeeId, r.AttendanceDate });
builder.HasIndex(r => r.AttendanceUploadBatchId);
}
@@ -19,7 +19,6 @@ public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguratio
builder.Property(b => b.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.UploadedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.PeriodStart, b.PeriodEnd });
@@ -13,7 +13,7 @@ public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
builder.Property(a => a.Action).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("longtext");
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict);
@@ -13,7 +13,7 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
public void Configure(EntityTypeBuilder<BranchConfig> builder)
{
builder.ToTable("branch_config", t =>
t.HasCheckConstraint("ck_branch_config_singleton", $"\"ConfigId\" = {BranchConfig.SingletonId}"));
t.HasCheckConstraint("ck_branch_config_singleton", $"`ConfigId` = {BranchConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
@@ -22,9 +22,6 @@ public sealed class BranchConfigConfiguration : IEntityTypeConfiguration<BranchC
builder.Property(c => c.BranchingEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
@@ -23,7 +23,6 @@ public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
@@ -21,9 +21,6 @@ public sealed class BrandConfiguration : IEntityTypeConfiguration<Brand>
builder.Property(b => b.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}
@@ -27,11 +27,6 @@ public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSal
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -20,11 +20,6 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.ConcurrencyStamp)
.IsRequired()
.HasColumnType("integer")
.HasDefaultValue(0)
.IsConcurrencyToken();
builder.HasMany(x => x.Lines)
.WithOne(x => x.BundleSaleTemplate)
@@ -21,9 +21,6 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
builder.Property(c => c.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -42,7 +42,6 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.CustomerType);
@@ -32,7 +32,6 @@ public sealed class DepartmentConfiguration : IEntityTypeConfiguration<Departmen
.HasForeignKey(d => d.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
builder.HasIndex(d => d.ParentDepartmentId);
@@ -21,7 +21,6 @@ public sealed class DesignationConfiguration : IEntityTypeConfiguration<Designat
.HasDefaultValue(EntityStatus.Active);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
}
@@ -26,7 +26,6 @@ public sealed class EmployeeBankDetailConfiguration : IEntityTypeConfiguration<E
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.EmployeeId);
}
@@ -63,7 +63,6 @@ public sealed class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
.HasDefaultValue(EmployeeStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
builder.HasIndex(e => e.DepartmentId);
@@ -28,7 +28,6 @@ public sealed class EmployeeDocumentConfiguration : IEntityTypeConfiguration<Emp
.HasForeignKey(d => d.HrDocumentTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.UploadedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.EmployeeId);
builder.HasIndex(d => d.ExpiryDate);
@@ -28,7 +28,6 @@ public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration<Employe
.HasForeignKey(i => i.EmployeeLoanId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => l.EmployeeId);
builder.HasIndex(l => l.Status);
@@ -49,8 +48,6 @@ public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration<Loan
builder.HasOne(i => i.PayrollRun).WithMany()
.HasForeignKey(i => i.PayrollRunId).OnDelete(DeleteBehavior.Restrict);
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasIndex(i => new { i.DueYear, i.DueMonth });
}
}
@@ -22,7 +22,6 @@ public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfigurat
.HasForeignKey(l => l.EmployeeSalaryStructureId).OnDelete(DeleteBehavior.Cascade);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => new { s.EmployeeId, s.EffectiveTo });
}
@@ -21,7 +21,6 @@ public sealed class EmploymentTypeConfiguration : IEntityTypeConfiguration<Emplo
.HasDefaultValue(EntityStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
}
@@ -17,7 +17,6 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.GlJournalNo).HasMaxLength(30);
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
@@ -22,7 +22,6 @@ public sealed class HrDocumentTypeConfiguration : IEntityTypeConfiguration<HrDoc
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
@@ -51,9 +51,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasOne(i => i.Category)
.WithMany()
.HasForeignKey(i => i.CategoryId)
@@ -30,9 +30,6 @@ public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -21,8 +21,6 @@ public sealed class LeaveBalanceConfiguration : IEntityTypeConfiguration<LeaveBa
builder.HasOne(b => b.LeaveType).WithMany()
.HasForeignKey(b => b.LeaveTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => new { b.EmployeeId, b.LeaveTypeId, b.Year }).IsUnique();
}
}
@@ -26,7 +26,6 @@ public sealed class LeaveRequestConfiguration : IEntityTypeConfiguration<LeaveRe
.HasForeignKey(r => r.LeaveTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => r.EmployeeId);
builder.HasIndex(r => r.Status);
@@ -22,7 +22,6 @@ public sealed class LeaveTypeConfiguration : IEntityTypeConfiguration<LeaveType>
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
@@ -9,13 +9,11 @@ public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<N
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
{
builder.ToTable("notification_config", t =>
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
t.HasCheckConstraint("ck_notification_config_singleton", $"`NotificationConfigId` = {NotificationConfig.SingletonId}"));
builder.HasKey(c => c.NotificationConfigId);
builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasMany(c => c.Modules)
.WithOne(m => m.NotificationConfig)
.HasForeignKey(m => m.NotificationConfigId)
@@ -20,7 +20,7 @@ public sealed class PayrollLineConfiguration : IEntityTypeConfiguration<PayrollL
nameof(PayrollLine.NetSalary)
})
{
builder.Property(money).HasColumnType("numeric(18,2)");
builder.Property(money).HasPrecision(18, 2);
}
builder.HasOne(l => l.Employee).WithMany()
@@ -29,8 +29,6 @@ public sealed class PayrollLineConfiguration : IEntityTypeConfiguration<PayrollL
builder.HasMany(l => l.Components).WithOne(c => c.PayrollLine!)
.HasForeignKey(c => c.PayrollLineId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => new { l.PayrollRunId, l.EmployeeId }).IsUnique();
}
}
@@ -23,7 +23,6 @@ public sealed class PayrollRunConfiguration : IEntityTypeConfiguration<PayrollRu
.HasForeignKey(l => l.PayrollRunId).OnDelete(DeleteBehavior.Cascade);
builder.Property(r => r.GeneratedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.PeriodYear, r.PeriodMonth, r.BranchId });
builder.HasIndex(r => r.Status);
@@ -17,7 +17,6 @@ public sealed class PayrollStatutorySettingConfiguration : IEntityTypeConfigurat
builder.Property(s => s.OtMultiplierDefault).HasPrecision(6, 2);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => s.EffectiveFrom);
}
@@ -35,7 +34,6 @@ public sealed class TaxSlabConfiguration : IEntityTypeConfiguration<TaxSlab>
builder.Property(s => s.Rate).HasPrecision(6, 4);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => s.EffectiveFrom);
}
@@ -15,7 +15,7 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
// The column is created as quoted PascalCase ("ConfigId"), so the constraint must
// quote it too — an unquoted config_id would fold to a column that does not exist.
builder.ToTable("product_config", t =>
t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}"));
t.HasCheckConstraint("ck_product_config_singleton", $"`ConfigId` = {ProductConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
@@ -29,9 +29,7 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.CustomVariantsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.ItemImagesEnabled).IsRequired().HasDefaultValue(false);
builder.Property(c => c.CustomWarrantyPeriodsEnabled).IsRequired().HasDefaultValue(false);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -32,13 +32,10 @@ public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration<P
// Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a
// typed/owned mapping would make AuditScribe emit audit rows for the nested entries.
builder.Property(t => t.Annotations).HasColumnType("jsonb");
builder.Property(t => t.Annotations).HasColumnType("longtext");
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
@@ -56,7 +53,7 @@ public sealed class TemplateStageConfiguration : IEntityTypeConfiguration<Templa
builder.Property(s => s.RoleLabel).HasMaxLength(60);
builder.Property(s => s.PosX).HasPrecision(18, 4);
builder.Property(s => s.PosY).HasPrecision(18, 4);
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("longtext");
builder.HasOne(s => s.Template).WithMany(t => t.Stages)
.HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);
@@ -135,7 +132,6 @@ public sealed class ProductionRunConfiguration : IEntityTypeConfiguration<Produc
builder.Property(r => r.ScaleFactor).HasPrecision(18, 6);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasOne(r => r.Template).WithMany(t => t.Runs)
.HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict);
@@ -162,9 +158,8 @@ public sealed class RunStageConfiguration : IEntityTypeConfiguration<RunStage>
builder.Property(s => s.PosX).HasPrecision(18, 4);
builder.Property(s => s.PosY).HasPrecision(18, 4);
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb");
builder.Property(s => s.FieldValues).HasColumnType("jsonb");
builder.Property(s => s.RowVersion).IsRowVersion();
builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("longtext");
builder.Property(s => s.FieldValues).HasColumnType("longtext");
builder.HasOne(s => s.Run).WithMany(r => r.Stages)
.HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade);
@@ -258,7 +253,7 @@ public sealed class RunStageEventConfiguration : IEntityTypeConfiguration<RunSta
builder.Property(e => e.EventType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(e => e.Note).HasMaxLength(500);
builder.Property(e => e.Payload).HasColumnType("jsonb");
builder.Property(e => e.Payload).HasColumnType("longtext");
builder.Property(e => e.CreatedAt).IsRequired();
builder.HasOne(e => e.Run).WithMany(r => r.Events)
@@ -40,7 +40,6 @@ public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<Purc
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.VendorId);
@@ -18,9 +18,6 @@ public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration<Purcha
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(p => p.RowVersion).IsRowVersion();
builder.HasOne(p => p.Vendor)
.WithMany()
.HasForeignKey(p => p.VendorId)
@@ -27,7 +27,5 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
builder.Property(r => r.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(r => r.RowVersion).IsRowVersion();
}
}
@@ -22,7 +22,6 @@ public sealed class SalaryComponentConfiguration : IEntityTypeConfiguration<Sala
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
@@ -25,7 +25,6 @@ public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration<SalesDay
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.ClosedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasOne(x => x.CashierUser).WithMany().HasForeignKey(x => x.CashierUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.ClosedByUser).WithMany().HasForeignKey(x => x.ClosedBy).OnDelete(DeleteBehavior.Restrict);
@@ -48,7 +48,6 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.InvoiceDate);
@@ -113,6 +112,5 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
}
@@ -42,7 +42,6 @@ public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.SlipDate);
@@ -95,6 +94,5 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
}
@@ -17,7 +17,6 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
builder.Property(a => a.RowVersion).IsRowVersion();
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
@@ -17,7 +17,6 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCoun
builder.Property(c => c.CountType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Branch).WithMany().HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
@@ -16,7 +16,6 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockT
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
@@ -20,9 +20,6 @@ public sealed class SubCategoryConfiguration : IEntityTypeConfiguration<SubCateg
builder.Property(s => s.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasOne(s => s.Category)
.WithMany(c => c.SubCategories)
.HasForeignKey(s => s.CategoryId)
@@ -13,7 +13,6 @@ public sealed class SupportTicketConfiguration : IEntityTypeConfiguration<Suppor
builder.Property(t => t.Subject).IsRequired().HasMaxLength(200);
builder.Property(t => t.Status).IsRequired().HasConversion<string>().HasMaxLength(20);
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.CreatedBy);
builder.HasIndex(t => t.Status);
@@ -24,9 +24,6 @@ public sealed class TaxRateConfiguration : IEntityTypeConfiguration<TaxRate>
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
// Seeded so Item.TaxRateId has something real to point at immediately — codes
@@ -34,9 +31,9 @@ public sealed class TaxRateConfiguration : IEntityTypeConfiguration<TaxRate>
// afterward (percentages are typically jurisdiction-specific).
var seededAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
builder.HasData(
new TaxRate { TaxRateId = 1, Code = "STANDARD", Name = "Standard", Percentage = 15m, CreatedAt = seededAt },
new TaxRate { TaxRateId = 2, Code = "ZERO", Name = "Zero-Rated", Percentage = 0m, CreatedAt = seededAt },
new TaxRate { TaxRateId = 3, Code = "EXEMPT", Name = "Exempt", Percentage = 0m, CreatedAt = seededAt }
new TaxRate { TaxRateId = 1, Code = "STANDARD", Name = "Standard", Percentage = 15m, CreatedAt = seededAt, RowVersion = 1 },
new TaxRate { TaxRateId = 2, Code = "ZERO", Name = "Zero-Rated", Percentage = 0m, CreatedAt = seededAt, RowVersion = 1 },
new TaxRate { TaxRateId = 3, Code = "EXEMPT", Name = "Exempt", Percentage = 0m, CreatedAt = seededAt, RowVersion = 1 }
);
}
}
@@ -26,9 +26,6 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(v => v.RowVersion).IsRowVersion();
builder.HasIndex(v => v.Status);
builder.HasOne(v => v.Branch).WithMany()
@@ -22,7 +22,6 @@ public sealed class WorkShiftConfiguration : IEntityTypeConfiguration<WorkShift>
.HasDefaultValue(EntityStatus.Active);
builder.Property(w => w.CreatedAt).IsRequired();
builder.Property(w => w.RowVersion).IsRowVersion();
builder.HasIndex(w => w.Status);
}
+53 -752
View File
@@ -1,15 +1,23 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3).
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity
/// sequence advances normally and later admin <c>POST /reason-codes</c> calls
/// cannot collide with seeded ids.
/// Idempotent startup seeding of the reference data the application cannot run without
/// (docs/10 §B.8.3): the default branch, reason codes, item types, and the three singleton
/// config rows. Navigation items, sub-nav items, permissions, tax rates and the `system`
/// user come from <c>HasData</c> in Infra/Persistence/Configurations instead, so the
/// baseline migration emits them as InsertData.
///
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity sequence
/// advances normally and later admin <c>POST /reason-codes</c> calls cannot collide with
/// seeded ids.
///
/// Demo/sample data (warehouses MAIN/SHOP, UOMs, categories, demo items, fabricated stock
/// layers, sample invoices/slips, bundle sales) was removed with the MySQL port: a fresh
/// database is now production-shaped. The e2e suite creates whatever fixtures it needs.
/// </summary>
public static class DataSeeder
{
@@ -41,21 +49,57 @@ public static class DataSeeder
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
// Must come first, and must save on its own: every branch-scoped entity has a
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
// until this row has a real generated id.
await EnsureDefaultBranchAsync(db, ct);
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
/// <summary>
/// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent.
/// </summary>
/// <remarks>
/// Before the MySQL port this row was inserted by raw SQL inside the BranchIsolationPhase1
/// migration, which the squash to a single baseline deleted — so on a genuinely fresh
/// database there would be no branch at all and the first branch-scoped insert would fail
/// on the FK. It is deliberately NOT <c>HasData</c>: branches are user-editable business
/// data, and an admin renaming this one through the UI would become permanent model drift
/// that EF tries to "correct" on the next migration.
///
/// <see cref="Services.BranchConfigService.ResolveDefaultBranchIdAsync"/> keeps its lazy
/// create as the runtime fallback, but seeding here means it never has to fire mid-request
/// (where it would do a nested SaveChanges inside a caller's UoW transaction).
/// </remarks>
private static async Task<int> EnsureDefaultBranchAsync(ErpDbContext db, CancellationToken ct)
{
var existingId = await db.Branches.AsNoTracking()
.Where(b => b.Code == Branch.DefaultCode)
.Select(b => b.BranchId)
.FirstOrDefaultAsync(ct);
if (existingId != 0) return existingId;
var branch = new Branch
{
Code = Branch.DefaultCode,
Name = Branch.DefaultName,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
db.Branches.Add(branch);
await db.SaveChangesAsync(ct);
return branch.BranchId;
}
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
{
var existing = await db.ReasonCodes
@@ -165,747 +209,4 @@ public static class DataSeeder
});
return true;
}
/// <summary>
/// Seeds a printable company profile with reasonable defaults for invoice headers.
/// These values are intentionally editable later through the API.
/// </summary>
//private static async Task<bool> SeedCompanyProfileAsync(ErpDbContext db, CancellationToken ct)
//{
// if (await db.CompanyProfiles.AnyAsync(c => c.CompanyProfileId == CompanyProfile.SingletonId, ct)) return false;
// db.CompanyProfiles.Add(new CompanyProfile
// {
// CompanyProfileId = CompanyProfile.SingletonId,
// LegalName = "ERP Core Trading (Pvt) Ltd",
// TradeName = "ERP Core Trading",
// TaxRegistrationNo = "TAX-DEFAULT-001",
// VatRegistrationNo = "VAT-DEFAULT-001",
// AddressLine1 = "1 Demo Street",
// City = "Colombo",
// Country = "Sri Lanka",
// Phone = "+94 11 000 0000",
// Email = "accounts@example.com",
// BankName = "Demo Bank",
// BankBranch = "Colombo Main",
// AccountName = "ERP Core Trading (Pvt) Ltd",
// AccountNumber = "000123456789",
// SwiftCode = "DEMO1234",
// FooterNote = "Thank you for your business."
// });
// return true;
//}
/// <summary>
/// Seeds the minimum catalog data required for the sales demo rows to exist.
/// These are safe additive rows and do not alter any existing data.
/// </summary>
private static async Task<bool> SeedSalesMastersAsync(ErpDbContext db, CancellationToken ct)
{
var dirty = false;
dirty |= await SeedWarehousesAsync(db, ct);
dirty |= await SeedUomsAsync(db, ct);
dirty |= await SeedCategoriesAsync(db, ct);
dirty |= await SeedItemsAsync(db, ct);
return dirty;
}
/// <summary>
/// Seeds a simple on-hand FIFO layer for the demo sales item so the sample
/// invoices can be posted without immediately failing stock validation.
/// This keeps the stock-check and posting flows testable on a fresh database.
/// </summary>
private static async Task<bool> SeedSalesStockAsync(ErpDbContext db, CancellationToken ct)
{
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var item = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.FirstOrDefaultAsync(ct);
var secondItem = await db.Items.AsNoTracking()
.OrderByDescending(i => i.ItemId)
.FirstOrDefaultAsync(ct);
if (warehouse is null || item is null || secondItem is null)
return false;
var existing = await db.StockLayers.AnyAsync(
l => (l.ItemId == item.ItemId || l.ItemId == secondItem.ItemId) && l.WarehouseId == warehouse.WarehouseId && l.QtyRemaining > 0m,
ct);
if (existing) return false;
db.StockLayers.Add(new StockLayer
{
ItemId = item.ItemId,
WarehouseId = warehouse.WarehouseId,
QtyReceived = 100m,
QtyRemaining = 100m,
UnitCost = item.SellingFlatPrice.GetValueOrDefault() > 0m ? item.SellingFlatPrice.GetValueOrDefault() / 2m : 25m,
ReceiptDate = DateTime.UtcNow.AddDays(-7)
});
db.StockLayers.Add(new StockLayer
{
ItemId = secondItem.ItemId,
WarehouseId = warehouse.WarehouseId,
QtyReceived = 5m,
QtyRemaining = 5m,
UnitCost = secondItem.SellingFlatPrice.GetValueOrDefault() > 0m ? secondItem.SellingFlatPrice.GetValueOrDefault() / 2m : 15m,
ReceiptDate = DateTime.UtcNow.AddDays(-6)
});
return true;
}
private static async Task<bool> SeedWarehousesAsync(ErpDbContext db, CancellationToken ct)
{
var existingCodes = await db.Warehouses.Select(w => w.Code).ToListAsync(ct);
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Warehouse { Code = "MAIN", Name = "Main Warehouse" },
new Warehouse { Code = "SHOP", Name = "Sales Counter" }
};
var toAdd = seeds.Where(w => !have.Contains(w.Code)).ToList();
if (toAdd.Count == 0) return false;
db.Warehouses.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedUomsAsync(ErpDbContext db, CancellationToken ct)
{
var existingNames = await db.Uoms.Select(u => u.Name).ToListAsync(ct);
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Uom { Name = "PCS" },
new Uom { Name = "BOX" }
};
var toAdd = seeds.Where(u => !have.Contains(u.Name)).ToList();
if (toAdd.Count == 0) return false;
db.Uoms.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedCategoriesAsync(ErpDbContext db, CancellationToken ct)
{
var existingNames = await db.Categories.Select(c => c.Name).ToListAsync(ct);
var have = existingNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Category { Name = "General Goods", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow },
new Category { Name = "Accessories", Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }
};
var toAdd = seeds.Where(c => !have.Contains(c.Name)).ToList();
if (toAdd.Count == 0) return false;
db.Categories.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedItemsAsync(ErpDbContext db, CancellationToken ct)
{
var existingSkus = await db.Items.Select(i => i.Sku).ToListAsync(ct);
var have = existingSkus.ToHashSet(StringComparer.OrdinalIgnoreCase);
var category = await db.Categories.AsNoTracking()
.OrderBy(c => c.CategoryId)
.FirstOrDefaultAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
if (category is null || uom is null)
return false;
var now = DateTime.UtcNow;
var seeds = new[]
{
new Item
{
Sku = "SKU-DEMO-001",
Name = "Demo Item 1",
Description = "Seeded sample item for sales documents",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SellingFlatPrice = 100m,
Status = EntityStatus.Active,
CreatedAt = now
},
new Item
{
Sku = "SKU-DEMO-002",
Name = "Demo Item 2 (500 ml)",
Description = "Secondary seeded sample item; carries a content size so the "
+ "production content-unit path has a fixture",
CategoryId = category.CategoryId,
BaseUomId = uom.UomId,
StockNature = StockNature.Stocked,
TrackingMode = TrackingMode.None,
SellingFlatPrice = 50m,
ContentQty = 500m,
ContentUnit = MeasureUnit.Ml,
ContentBaseQty = 500m,
ContentBaseUnit = MeasureUnit.Ml,
Status = EntityStatus.Active,
CreatedAt = now
}
};
var toAdd = seeds.Where(i => !have.Contains(i.Sku)).ToList();
if (toAdd.Count == 0) return false;
db.Items.AddRange(toAdd);
return true;
}
/// <summary>
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
/// a couple of customer rows, current-year document counters, and a few draft
/// invoice/slip samples when the required master data already exists.
/// This intentionally never clears or rewrites any existing rows.
/// </summary>
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
{
var dirty = false;
dirty |= await SeedSalesCustomersAsync(db, ct);
dirty |= await SeedSalesSequencesAsync(db, ct);
dirty |= await SeedSampleSalesDocsAsync(db, ct);
try
{
dirty |= await SeedBundleSalesAsync(db, ct);
}
catch
{
// Bundle demo data is best-effort only; never block startup because of seed drift.
}
return dirty;
}
private static async Task<bool> SeedSalesCustomersAsync(ErpDbContext db, CancellationToken ct)
{
var existingCodes = await db.Customers.Select(c => c.CustomerCode).ToListAsync(ct);
var have = existingCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new Customer
{
CustomerCode = "CUST-WALKIN",
CustomerType = CustomerType.B2C,
Name = "Walk-in Customer",
DisplayName = "Walk-in Customer",
CreditLimit = 0m,
CreditDays = 0,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
},
new Customer
{
CustomerCode = "CUST-DEMO",
CustomerType = CustomerType.B2B,
Name = "Demo Retail Ltd",
DisplayName = "Demo Retail Ltd",
Phone = "+94 11 000 0000",
Email = "sales@example.com",
AddressLine1 = "1 Demo Street",
City = "Colombo",
Country = "Sri Lanka",
TaxRegistrationNo = "VAT-DEMO-001",
CreditLimit = 250000m,
CreditDays = 30,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
}
};
var toAdd = seeds.Where(c => !have.Contains(c.CustomerCode)).ToList();
if (toAdd.Count == 0) return false;
db.Customers.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedSalesSequencesAsync(ErpDbContext db, CancellationToken ct)
{
var year = DateTime.UtcNow.Year;
var existing = await db.NumberSequences
.Where(s => s.Year == year && (s.DocType == DocumentTypes.SalesInvoice || s.DocType == DocumentTypes.SalesSlip || s.DocType == DocumentTypes.BundleSale))
.Select(s => s.DocType)
.ToListAsync(ct);
var have = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
var seeds = new[]
{
new NumberSequence { DocType = DocumentTypes.SalesInvoice, Year = year, LastNumber = 0 },
new NumberSequence { DocType = DocumentTypes.SalesSlip, Year = year, LastNumber = 0 },
new NumberSequence { DocType = DocumentTypes.BundleSale, Year = year, LastNumber = 0 }
};
var toAdd = seeds.Where(s => !have.Contains(s.DocType)).ToList();
if (toAdd.Count == 0) return false;
db.NumberSequences.AddRange(toAdd);
return true;
}
private static async Task<bool> SeedBundleSalesAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.BundleSaleTemplates.AnyAsync(ct) || await db.BundleSales.AnyAsync(ct))
return false;
var customer = await db.Customers.AsNoTracking()
.OrderBy(c => c.CustomerId)
.FirstOrDefaultAsync(ct);
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
.OrderByDescending(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var items = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.Take(2)
.ToListAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
var user = await db.Users.AsNoTracking()
.OrderBy(u => u.UserId)
.FirstOrDefaultAsync(ct);
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
return false;
var now = DateTime.UtcNow;
var template = new BundleSaleTemplate
{
TemplateCode = "BND-DEMO-001",
TemplateName = "Demo Bundle Pack",
Description = "Seeded fixed bundle template for integration testing",
Status = EntityStatus.Active,
CreatedAt = now,
Lines =
[
new BundleSaleTemplateLine
{
ItemId = items[0].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 1
},
new BundleSaleTemplateLine
{
ItemId = items[1].ItemId,
WarehouseId = warehouse.WarehouseId,
Qty = 1m,
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
SortOrder = 2
}
]
};
db.BundleSaleTemplates.Add(template);
await db.SaveChangesAsync(ct);
var bundleSales = new[]
{
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00001",
BundleDate = now.Date.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Draft",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Draft,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = 0m,
MarginAmount = -(items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()),
DiscountTotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
TaxTotal = 0m,
GrandTotal = 0m,
CreatedAt = now.AddDays(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
},
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00002",
BundleDate = now.Date.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Posted",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Posted,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
MarginAmount = -10m,
DiscountTotal = 10m,
TaxTotal = 0m,
GrandTotal = (items[0].SellingFlatPrice.GetValueOrDefault() + items[1].SellingFlatPrice.GetValueOrDefault()) - 10m,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-2),
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
},
new BundleSaleLine
{
ItemId = items[1].ItemId,
Description = items[1].Name,
Qty = 1m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = items[1].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[1].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
},
new BundleSale
{
BundleNo = $"BND-{now:yyyy}-00003",
BundleDate = now.Date,
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.DisplayName ?? customer.Name,
WarehouseId = secondaryWarehouse.WarehouseId,
CashierUserId = user.UserId,
BundleSaleTemplateId = template.BundleSaleTemplateId,
BundleName = "Demo Bundle Cancelled",
BundleCode = "BND-DEMO-001",
Status = BundleSaleStatus.Cancelled,
ComponentSubtotal = items[0].SellingFlatPrice.GetValueOrDefault(),
BundlePrice = items[0].SellingFlatPrice.GetValueOrDefault(),
MarginAmount = 0m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
CreatedAt = now,
UpdatedAt = now,
Lines =
[
new BundleSaleLine
{
ItemId = items[0].ItemId,
Description = items[0].Name,
Qty = 1m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = items[0].SellingFlatPrice.GetValueOrDefault(),
LineTotal = items[0].SellingFlatPrice.GetValueOrDefault(),
IncludeInBundle = true,
IsComponent = true
}
]
}
};
db.BundleSales.AddRange(bundleSales);
return true;
}
private static async Task<bool> SeedSampleSalesDocsAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.SalesInvoices.AnyAsync(ct) || await db.SalesSlips.AnyAsync(ct))
return false;
var customer = await db.Customers.AsNoTracking()
.OrderBy(c => c.CustomerId)
.FirstOrDefaultAsync(ct);
var warehouse = await db.Warehouses.AsNoTracking()
.OrderBy(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var secondaryWarehouse = await db.Warehouses.AsNoTracking()
.OrderByDescending(w => w.WarehouseId)
.FirstOrDefaultAsync(ct);
var items = await db.Items.AsNoTracking()
.OrderBy(i => i.ItemId)
.Take(2)
.ToListAsync(ct);
var uom = await db.Uoms.AsNoTracking()
.OrderBy(u => u.UomId)
.FirstOrDefaultAsync(ct);
var user = await db.Users.AsNoTracking()
.OrderBy(u => u.UserId)
.FirstOrDefaultAsync(ct);
if (customer is null || warehouse is null || secondaryWarehouse is null || items.Count < 2 || uom is null || user is null)
return false;
var postableItem = items[0];
var shortageItem = items[1];
var today = DateTime.UtcNow.Date;
var createdAt = DateTime.UtcNow.AddDays(-1);
db.SalesInvoices.AddRange(
new SalesInvoice
{
InvoiceNo = $"SI-{today:yyyy}-00001",
InvoiceDate = today.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
WarehouseId = warehouse.WarehouseId,
InvoiceType = SalesInvoiceType.B2C,
Status = SalesInvoiceStatus.Draft,
Subtotal = 200m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 200m,
RoundOff = 0m,
NetPayable = 200m,
PaidAmount = 0m,
BalanceAmount = 200m,
CreatedBy = user.UserId,
CreatedAt = createdAt,
Lines =
[
new SalesInvoiceLine
{
ItemId = postableItem.ItemId,
Description = postableItem.Name,
Qty = 2m,
FreeQty = 0m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 100m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 100m,
LineTotal = 200m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
},
new SalesInvoice
{
InvoiceNo = $"SI-{today:yyyy}-00002",
InvoiceDate = today.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
WarehouseId = secondaryWarehouse.WarehouseId,
InvoiceType = SalesInvoiceType.B2B,
Status = SalesInvoiceStatus.Draft,
Subtotal = 300m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 300m,
RoundOff = 0m,
NetPayable = 300m,
PaidAmount = 0m,
BalanceAmount = 300m,
CreatedBy = user.UserId,
CreatedAt = createdAt,
Lines =
[
new SalesInvoiceLine
{
ItemId = shortageItem.ItemId,
Description = shortageItem.Name,
Qty = 6m,
FreeQty = 0m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 50m,
LineTotal = 300m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
},
new SalesInvoice
{
InvoiceNo = $"SI-{today:yyyy}-00003",
InvoiceDate = today,
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
CustomerSnapshotTaxNo = customer.TaxRegistrationNo,
WarehouseId = warehouse.WarehouseId,
InvoiceType = SalesInvoiceType.B2C,
Status = SalesInvoiceStatus.Posted,
Subtotal = 100m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 100m,
RoundOff = 0m,
NetPayable = 100m,
PaidAmount = 100m,
BalanceAmount = 0m,
CreatedBy = user.UserId,
CreatedAt = createdAt,
UpdatedAt = DateTime.UtcNow,
Lines =
[
new SalesInvoiceLine
{
ItemId = postableItem.ItemId,
Description = postableItem.Name,
Qty = 1m,
FreeQty = 0m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 100m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 100m,
LineTotal = 100m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
}
);
db.SalesSlips.AddRange(
new SalesSlip
{
SlipNo = $"SSL-{today:yyyy}-00001",
SlipDate = today.AddDays(-2),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
WarehouseId = warehouse.WarehouseId,
CashierUserId = user.UserId,
Status = SalesSlipStatus.Draft,
Subtotal = 50m,
DiscountTotal = 0m,
TaxTotal = 0m,
GrandTotal = 50m,
PaidAmount = 0m,
BalanceAmount = 50m,
CreatedAt = createdAt,
Lines =
[
new SalesSlipLine
{
ItemId = postableItem.ItemId,
Description = postableItem.Name,
Qty = 1m,
FreeQty = 0m,
WarehouseId = warehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 0m,
DiscountAmount = 0m,
NetUnitPrice = 50m,
LineTotal = 50m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
},
new SalesSlip
{
SlipNo = $"SSL-{today:yyyy}-00002",
SlipDate = today.AddDays(-1),
CustomerId = customer.CustomerId,
CustomerSnapshotName = customer.Name,
WarehouseId = secondaryWarehouse.WarehouseId,
CashierUserId = user.UserId,
Status = SalesSlipStatus.Draft,
Subtotal = 150m,
DiscountTotal = 15m,
TaxTotal = 0m,
GrandTotal = 135m,
PaidAmount = 0m,
BalanceAmount = 135m,
CreatedAt = createdAt,
Lines =
[
new SalesSlipLine
{
ItemId = shortageItem.ItemId,
Description = shortageItem.Name,
Qty = 3m,
FreeQty = 0m,
WarehouseId = secondaryWarehouse.WarehouseId,
UnitPrice = 50m,
BaseCost = 0m,
PriceSource = "seed",
DiscountMode = SalesDiscountMode.Percentage,
DiscountPct = 10m,
DiscountAmount = 15m,
NetUnitPrice = 45m,
LineTotal = 135m,
TaxPct = 0m,
TaxAmount = 0m,
IsFreeIssue = false
}
]
}
);
return true;
}
}
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence.Auditing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
namespace ERPCore.Infra.Persistence;
@@ -189,6 +190,15 @@ public class ErpDbContext : DbContext
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
// Pin the collation on the model. Pomelo emits `CharSet utf8mb4` per table/column but
// no collation, so without this every column silently takes the charset's server
// default — utf8mb4_general_ci on MariaDB — regardless of what the database is set to.
// utf8mb4_unicode_ci is case-insensitive (which is the decided semantics: it keeps the
// 48 EF.Functions.Like searches behaving like the old ILIKE, and makes the uniqueness
// guards consistent with their backing indexes) but uses proper UCA ordering rather
// than general_ci's simplified table. MariaDB has no utf8mb4_0900_ai_ci.
modelBuilder.UseCollation("utf8mb4_unicode_ci");
// Global read-side branch scoping (docs/BRANCH_ISOLATION_PLAN.md). Only for entities
// that carry an explicit BranchId directly — warehouse-inherited entities (Grn,
// StockLayer, StockTransfer, ...) are NOT covered here (would need an extra join in
@@ -202,10 +212,35 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns
// to have Kind=Utc; dates deserialized from a JSON request body (hire date,
// salary-structure effective date, etc.) come in as Kind=Unspecified and would
// otherwise throw at SaveChanges time. Force Utc kind globally for every
// Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system
// column via .IsRowVersion(), which the engine maintained for free. MariaDB has no
// equivalent: Pomelo maps .IsRowVersion() on a uint to a plain `int unsigned` column
// marked ValueGeneratedOnAddOrUpdate, so EF expects the *database* to bump it and the
// database never does — every row would sit at 0 and the concurrency check would
// silently always pass. So the column is real, generated by nothing, and incremented
// by BumpRowVersions() in the SaveChanges overrides below.
//
// Applied as a convention rather than 49 hand edits: a loop cannot miss an entity,
// and a missed one is invisible (a non-token column, no error, no protection).
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var rowVersion = entityType.FindProperty(RowVersionProperty);
if (rowVersion is null || rowVersion.ClrType != typeof(uint)) continue;
rowVersion.IsConcurrencyToken = true;
// ValueGenerated.Never is load-bearing: anything else and EF refuses to write the
// value we compute. Deliberately no HasDefaultValue(0) either — a store default
// makes EF omit the column from INSERTs and treat it as store-generated.
rowVersion.ValueGenerated = ValueGenerated.Never;
rowVersion.IsNullable = false;
}
// MariaDB does not have `timestamp with time zone`; values are stored as datetime(6)
// in UTC. This converter is still load-bearing (do NOT delete it as a Postgres-only
// artifact): dates deserialized from a JSON request body (hire date, salary-structure
// effective date, etc.) arrive as Kind=Unspecified, and MySqlConnector hands values
// back as Kind=Unspecified too, so both directions need pinning. Force Utc kind
// globally for every
// DateTime/DateTime? property rather than remembering to convert at each HRM
// service call site (docs/12-BACKEND-HRM.md — new in Phase 2; Phase 1 never hit
// this because it only ever persisted server-generated DateTime.UtcNow values).
@@ -226,14 +261,60 @@ public class ErpDbContext : DbContext
}
}
/// <summary>
/// Name of the optimistic-concurrency token property. Must stay <c>"RowVersion"</c>:
/// the CLR type <c>uint</c> is what lets <see cref="Common.Http.ETag"/> and every
/// <c>If-Match</c> call site stay unchanged.
/// </summary>
internal const string RowVersionProperty = "RowVersion";
/// <summary>
/// Increments the concurrency token for every inserted or modified entity that carries
/// one. Replaces what PostgreSQL's <c>xmin</c> did automatically.
/// </summary>
private void BumpRowVersions()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified)) continue;
var meta = entry.Metadata.FindProperty(RowVersionProperty);
if (meta is null || meta.ClrType != typeof(uint)) continue;
var p = entry.Property(RowVersionProperty);
// New rows start at 1, never 0: ETag.TryParse("AAAAAA==") succeeds and yields 0,
// so a client sending the all-zero token (exactly what a default-initialised uint
// produces) would otherwise pass the pre-check on every never-updated row.
// Starting at 1 keeps 0 permanently non-live.
//
// Updates increment OriginalValue, NOT CurrentValue. EF emits
// SET RowVersion = @new WHERE pk = @pk AND RowVersion = @orig
// taking WHERE from OriginalValue and SET from CurrentValue. Basing the new value
// on CurrentValue would break the moment anything else touches the property (a DTO
// mapper, a detached graph attach), producing a value unrelated to the row and an
// intermittent spurious 412 on the happy path.
p.CurrentValue = entry.State == EntityState.Added
? 1u
: (uint)(p.OriginalValue ?? 0u) + 1u;
}
}
// Audit trail (FR-X-02): capture mutations before save (accurate old→new), then
// write the log rows once inserts have their generated keys. A second base save
// persists the logs without re-auditing them.
//
// BumpRowVersions runs AFTER AuditScribe.Capture (which has already serialised each
// change set into an immutable string, so the token bump physically cannot leak into an
// audit payload) and BEFORE the first base save. It is called here rather than from a
// SaveChangesInterceptor precisely because base.Save… is invoked twice below — an
// interceptor would fire on both and double-bump.
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
? Array.Empty<PendingAudit>()
: AuditScribe.Capture(ChangeTracker);
BumpRowVersions();
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
if (pending.Count > 0)
{
@@ -256,6 +337,7 @@ public class ErpDbContext : DbContext
IReadOnlyList<PendingAudit> pending = _writingAuditLogs
? Array.Empty<PendingAudit>()
: AuditScribe.Capture(ChangeTracker);
BumpRowVersions();
var result = base.SaveChanges(acceptAllChangesOnSuccess);
if (pending.Count > 0)
{
+13 -3
View File
@@ -1,12 +1,22 @@
using System.Data;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.UoW;
/// <summary>
/// EF Core-backed unit of work. Uses the provider execution strategy so the
/// transactional path is safe under connection resiliency / retries.
/// EF Core-backed unit of work. Opens every transaction at READ COMMITTED to match the
/// semantics the code was written against (MariaDB defaults to REPEATABLE READ, where a
/// plain read inside a transaction returns a pinned snapshot while a locking read does not).
/// </summary>
/// <remarks>
/// Do NOT enable <c>EnableRetryOnFailure</c> to "harden" this. The execution strategy is
/// currently non-retrying, so the lambda runs exactly once. With retries on, EF re-runs the
/// lambda without resetting the ChangeTracker, and callers such as
/// <c>ProductionRunService.ApproveStageAsync</c> capture mutable state outside it — a retry
/// would re-insert already-Added entities and duplicate ledger/layer rows, which is precisely
/// the corruption the concurrency token exists to prevent.
/// </remarks>
public sealed class UnitOfWork : IUnitOfWork
{
private readonly ErpDbContext _db;
@@ -29,7 +39,7 @@ public sealed class UnitOfWork : IUnitOfWork
var strategy = _db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
var result = await action(ct);