adding complete db migrations from posgres
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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);
|
||||
|
||||
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user