develop full initial module

This commit is contained in:
Dhananjaya99
2026-07-23 19:54:56 +05:30
parent eacc21afad
commit 755df494fe
188 changed files with 13894 additions and 49 deletions
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration<AttendanceRecord>
{
public void Configure(EntityTypeBuilder<AttendanceRecord> builder)
{
builder.ToTable("hr_attendance_records");
builder.HasKey(r => r.AttendanceRecordId);
builder.Property(r => r.Notes).HasMaxLength(1000);
builder.Property(r => r.AttendanceStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.RowValidationStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(r => r.AttendanceUploadBatch).WithMany()
.HasForeignKey(r => r.AttendanceUploadBatchId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(r => r.Employee).WithMany()
.HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict);
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);
}
}
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguration<AttendanceUploadBatch>
{
public void Configure(EntityTypeBuilder<AttendanceUploadBatch> builder)
{
builder.ToTable("hr_attendance_upload_batches");
builder.HasKey(b => b.AttendanceUploadBatchId);
builder.Property(b => b.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(b => b.DocNo).IsUnique();
builder.Property(b => b.OriginalFileName).HasMaxLength(260);
builder.Property(b => b.SourceType).HasConversion<string>().HasMaxLength(20).IsRequired();
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 });
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
{
public void Configure(EntityTypeBuilder<Branch> builder)
{
builder.ToTable("hr_branches");
builder.HasKey(b => b.BranchId);
builder.Property(b => b.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500);
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class DepartmentConfiguration : IEntityTypeConfiguration<Department>
{
public void Configure(EntityTypeBuilder<Department> builder)
{
builder.ToTable("hr_departments");
builder.HasKey(d => d.DepartmentId);
builder.Property(d => d.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(d => d.Code).IsUnique();
builder.Property(d => d.Name).IsRequired().HasMaxLength(200);
builder.Property(d => d.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
// Self-nesting (unlimited depth, unlike Category) — cycle prevention is
// service-level, not a DB constraint (docs/12-BACKEND-HRM.md A.1/C.1).
builder.HasOne(d => d.ParentDepartment).WithMany()
.HasForeignKey(d => d.ParentDepartmentId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(d => d.HeadEmployee).WithMany()
.HasForeignKey(d => d.HeadEmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(d => d.Branch).WithMany()
.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);
}
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class DesignationConfiguration : IEntityTypeConfiguration<Designation>
{
public void Configure(EntityTypeBuilder<Designation> builder)
{
builder.ToTable("hr_designations");
builder.HasKey(d => d.DesignationId);
builder.Property(d => d.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(d => d.Code).IsUnique();
builder.Property(d => d.Name).IsRequired().HasMaxLength(200);
builder.Property(d => d.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
}
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeBankDetailConfiguration : IEntityTypeConfiguration<EmployeeBankDetail>
{
public void Configure(EntityTypeBuilder<EmployeeBankDetail> builder)
{
builder.ToTable("hr_employee_bank_details");
builder.HasKey(b => b.EmployeeBankDetailId);
builder.Property(b => b.BankName).IsRequired().HasMaxLength(200);
builder.Property(b => b.BranchName).IsRequired().HasMaxLength(200);
builder.Property(b => b.AccountNumber).IsRequired().HasMaxLength(50);
builder.Property(b => b.AccountHolderName).IsRequired().HasMaxLength(200);
builder.Property(b => b.SwiftCode).HasMaxLength(20);
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasOne(b => b.Employee).WithMany()
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.EmployeeId);
}
}
@@ -0,0 +1,71 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
public void Configure(EntityTypeBuilder<Employee> builder)
{
builder.ToTable("hr_employees");
builder.HasKey(e => e.EmployeeId);
builder.Property(e => e.EmployeeCode).IsRequired().HasMaxLength(30);
builder.HasIndex(e => e.EmployeeCode).IsUnique();
builder.Property(e => e.FullName).IsRequired().HasMaxLength(200);
builder.Property(e => e.Nic).HasMaxLength(30);
builder.Property(e => e.Nationality).HasMaxLength(100);
builder.Property(e => e.ProfilePhotoPath).HasMaxLength(500);
builder.Property(e => e.Gender).HasConversion<string>().HasMaxLength(20);
builder.Property(e => e.Email).HasMaxLength(320);
builder.HasIndex(e => e.Email);
builder.Property(e => e.PersonalMobile).HasMaxLength(30);
builder.Property(e => e.AddressLine1).HasMaxLength(200);
builder.Property(e => e.AddressLine2).HasMaxLength(200);
builder.Property(e => e.City).HasMaxLength(100);
builder.Property(e => e.PostalCode).HasMaxLength(20);
builder.Property(e => e.Country).HasMaxLength(100);
builder.Property(e => e.EmergencyContactName).HasMaxLength(200);
builder.Property(e => e.EmergencyContactRelationship).HasMaxLength(100);
builder.Property(e => e.EmergencyContactPhone).HasMaxLength(30);
builder.Property(e => e.EpfNumber).HasMaxLength(30);
builder.Property(e => e.EtfNumber).HasMaxLength(30);
builder.Property(e => e.TaxIdentificationNumber).HasMaxLength(30);
builder.HasOne(e => e.Department).WithMany()
.HasForeignKey(e => e.DepartmentId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Designation).WithMany()
.HasForeignKey(e => e.DesignationId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.EmploymentType).WithMany()
.HasForeignKey(e => e.EmploymentTypeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Branch).WithMany()
.HasForeignKey(e => e.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.WorkShift).WithMany()
.HasForeignKey(e => e.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ReportingManager).WithMany()
.HasForeignKey(e => e.ReportingManagerId).OnDelete(DeleteBehavior.Restrict);
// One User backs at most one Employee (docs/12-BACKEND-HRM.md A.5/C.2).
// Postgres unique indexes allow multiple NULLs natively, so no explicit
// filter is needed (same pattern as User.AuthUserId).
builder.HasOne(e => e.User).WithOne()
.HasForeignKey<Employee>(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => e.UserId).IsUnique();
builder.Property(e => e.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.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);
}
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeDocumentConfiguration : IEntityTypeConfiguration<EmployeeDocument>
{
public void Configure(EntityTypeBuilder<EmployeeDocument> builder)
{
builder.ToTable("hr_employee_documents");
builder.HasKey(d => d.EmployeeDocumentId);
builder.Property(d => d.OriginalFileName).IsRequired().HasMaxLength(260);
builder.Property(d => d.StoredFileName).IsRequired().HasMaxLength(260);
builder.Property(d => d.RelativePath).IsRequired().HasMaxLength(500);
builder.Property(d => d.ContentType).IsRequired().HasMaxLength(200);
builder.Property(d => d.Notes).HasMaxLength(1000);
builder.Property(d => d.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EmployeeDocumentStatus.Active);
builder.HasOne(d => d.Employee).WithMany()
.HasForeignKey(d => d.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(d => d.HrDocumentType).WithMany()
.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);
}
}
@@ -0,0 +1,56 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration<EmployeeLoan>
{
public void Configure(EntityTypeBuilder<EmployeeLoan> builder)
{
builder.ToTable("hr_employee_loans");
builder.HasKey(l => l.EmployeeLoanId);
builder.Property(l => l.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(l => l.DocNo).IsUnique();
builder.Property(l => l.LoanKind).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(l => l.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(l => l.PrincipalAmount).HasPrecision(18, 2);
builder.Property(l => l.InterestRate).HasPrecision(6, 4);
builder.Property(l => l.InstallmentAmount).HasPrecision(18, 2);
builder.Property(l => l.OutstandingBalance).HasPrecision(18, 2);
builder.HasOne(l => l.Employee).WithMany()
.HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(l => l.Installments).WithOne(i => i.EmployeeLoan!)
.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);
}
}
public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration<LoanInstallment>
{
public void Configure(EntityTypeBuilder<LoanInstallment> builder)
{
builder.ToTable("hr_loan_installments");
builder.HasKey(i => i.LoanInstallmentId);
builder.Property(i => i.ScheduledAmount).HasPrecision(18, 2);
builder.Property(i => i.PaidAmount).HasPrecision(18, 2);
builder.Property(i => i.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
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 });
}
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfiguration<EmployeeSalaryStructure>
{
public void Configure(EntityTypeBuilder<EmployeeSalaryStructure> builder)
{
builder.ToTable("hr_employee_salary_structures");
builder.HasKey(s => s.EmployeeSalaryStructureId);
builder.Property(s => s.BasicSalary).HasPrecision(18, 2);
builder.Property(s => s.Currency).IsRequired().HasMaxLength(3);
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(s => s.Employee).WithMany()
.HasForeignKey(s => s.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(s => s.Lines).WithOne(l => l.EmployeeSalaryStructure!)
.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 });
}
}
public sealed class EmployeeSalaryStructureLineConfiguration : IEntityTypeConfiguration<EmployeeSalaryStructureLine>
{
public void Configure(EntityTypeBuilder<EmployeeSalaryStructureLine> builder)
{
builder.ToTable("hr_employee_salary_structure_lines");
builder.HasKey(l => l.EmployeeSalaryStructureLineId);
builder.Property(l => l.Amount).HasPrecision(18, 2);
builder.HasOne(l => l.SalaryComponent).WithMany()
.HasForeignKey(l => l.SalaryComponentId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmploymentTypeConfiguration : IEntityTypeConfiguration<EmploymentType>
{
public void Configure(EntityTypeBuilder<EmploymentType> builder)
{
builder.ToTable("hr_employment_types");
builder.HasKey(e => e.EmploymentTypeId);
builder.Property(e => e.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(e => e.Code).IsUnique();
builder.Property(e => e.Name).IsRequired().HasMaxLength(200);
builder.Property(e => e.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class HrDocumentTypeConfiguration : IEntityTypeConfiguration<HrDocumentType>
{
public void Configure(EntityTypeBuilder<HrDocumentType> builder)
{
builder.ToTable("hr_document_types");
builder.HasKey(t => t.HrDocumentTypeId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.Property(t => t.Category).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LeaveBalanceConfiguration : IEntityTypeConfiguration<LeaveBalance>
{
public void Configure(EntityTypeBuilder<LeaveBalance> builder)
{
builder.ToTable("hr_leave_balances");
builder.HasKey(b => b.LeaveBalanceId);
builder.Property(b => b.EntitledDays).HasPrecision(6, 2);
builder.Property(b => b.TakenDays).HasPrecision(6, 2);
builder.Property(b => b.CarriedForwardDays).HasPrecision(6, 2);
builder.Property(b => b.AdjustmentDays).HasPrecision(6, 2);
builder.HasOne(b => b.Employee).WithMany()
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Restrict);
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();
}
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LeaveRequestConfiguration : IEntityTypeConfiguration<LeaveRequest>
{
public void Configure(EntityTypeBuilder<LeaveRequest> builder)
{
builder.ToTable("hr_leave_requests");
builder.HasKey(r => r.LeaveRequestId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.DaysCount).HasPrecision(6, 2);
builder.Property(r => r.Reason).HasMaxLength(1000);
builder.Property(r => r.RejectionReason).HasMaxLength(1000);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(r => r.Employee).WithMany()
.HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.LeaveType).WithMany()
.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);
builder.HasIndex(r => new { r.StartDate, r.EndDate });
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LeaveTypeConfiguration : IEntityTypeConfiguration<LeaveType>
{
public void Configure(EntityTypeBuilder<LeaveType> builder)
{
builder.ToTable("hr_leave_types");
builder.HasKey(t => t.LeaveTypeId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.Property(t => t.AccrualPerYear).HasPrecision(6, 2);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -0,0 +1,52 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayrollLineConfiguration : IEntityTypeConfiguration<PayrollLine>
{
public void Configure(EntityTypeBuilder<PayrollLine> builder)
{
builder.ToTable("hr_payroll_lines");
builder.HasKey(l => l.PayrollLineId);
foreach (var money in new[]
{
nameof(PayrollLine.BasicSalary), nameof(PayrollLine.TotalAllowances), nameof(PayrollLine.OvertimeAmount),
nameof(PayrollLine.GrossSalary), nameof(PayrollLine.LateDeductionAmount), nameof(PayrollLine.NoPayAmount),
nameof(PayrollLine.LoanDeductionAmount), nameof(PayrollLine.EpfEmployeeAmount), nameof(PayrollLine.EpfEmployerAmount),
nameof(PayrollLine.EtfEmployerAmount), nameof(PayrollLine.TaxAmount), nameof(PayrollLine.OtherDeductionsAmount),
nameof(PayrollLine.NetSalary)
})
{
builder.Property(money).HasColumnType("numeric(18,2)");
}
builder.HasOne(l => l.Employee).WithMany()
.HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict);
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();
}
}
public sealed class PayrollLineComponentConfiguration : IEntityTypeConfiguration<PayrollLineComponent>
{
public void Configure(EntityTypeBuilder<PayrollLineComponent> builder)
{
builder.ToTable("hr_payroll_line_components");
builder.HasKey(c => c.PayrollLineComponentId);
builder.Property(c => c.ComponentCategory).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Label).IsRequired().HasMaxLength(200);
builder.Property(c => c.Amount).HasPrecision(18, 2);
builder.HasOne(c => c.SalaryComponent).WithMany()
.HasForeignKey(c => c.SalaryComponentId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayrollRunConfiguration : IEntityTypeConfiguration<PayrollRun>
{
public void Configure(EntityTypeBuilder<PayrollRun> builder)
{
builder.ToTable("hr_payroll_runs");
builder.HasKey(r => r.PayrollRunId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.UnlockReason).HasMaxLength(500);
builder.HasOne(r => r.Branch).WithMany()
.HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(r => r.Lines).WithOne(l => l.PayrollRun!)
.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);
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayrollStatutorySettingConfiguration : IEntityTypeConfiguration<PayrollStatutorySetting>
{
public void Configure(EntityTypeBuilder<PayrollStatutorySetting> builder)
{
builder.ToTable("hr_payroll_statutory_settings");
builder.HasKey(s => s.PayrollStatutorySettingId);
builder.Property(s => s.EpfEmployeeRate).HasPrecision(6, 4);
builder.Property(s => s.EpfEmployerRate).HasPrecision(6, 4);
builder.Property(s => s.EtfEmployerRate).HasPrecision(6, 4);
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);
}
}
public sealed class TaxSlabConfiguration : IEntityTypeConfiguration<TaxSlab>
{
public void Configure(EntityTypeBuilder<TaxSlab> builder)
{
builder.ToTable("hr_tax_slabs");
builder.HasKey(s => s.TaxSlabId);
builder.Property(s => s.LowerBound).HasPrecision(18, 2);
builder.Property(s => s.UpperBound).HasPrecision(18, 2);
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);
}
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayslipConfiguration : IEntityTypeConfiguration<Payslip>
{
public void Configure(EntityTypeBuilder<Payslip> builder)
{
builder.ToTable("hr_payslips");
builder.HasKey(p => p.PayslipId);
builder.HasOne(p => p.PayrollLine).WithOne()
.HasForeignKey<Payslip>(p => p.PayrollLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => p.PayrollLineId).IsUnique();
builder.Property(p => p.GeneratedAt).IsRequired();
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalaryComponentConfiguration : IEntityTypeConfiguration<SalaryComponent>
{
public void Configure(EntityTypeBuilder<SalaryComponent> builder)
{
builder.ToTable("hr_salary_components");
builder.HasKey(c => c.SalaryComponentId);
builder.Property(c => c.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(c => c.Code).IsUnique();
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.Property(c => c.ComponentType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -23,6 +23,12 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
builder.HasIndex(u => u.AuthUserId).IsUnique();
// Employee<->User cross-link match field (docs/12-BACKEND-HRM.md A.5). Unique,
// like AuthUserId — Postgres allows multiple NULLs in a unique index natively,
// so employees/users without an email don't collide.
builder.Property(u => u.Email).HasMaxLength(320);
builder.HasIndex(u => u.Email).IsUnique();
// Local shadow Role assignment (nullable — unset until an admin assigns one).
builder.HasOne(u => u.Role).WithMany()
.HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict);
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class WorkShiftConfiguration : IEntityTypeConfiguration<WorkShift>
{
public void Configure(EntityTypeBuilder<WorkShift> builder)
{
builder.ToTable("hr_work_shifts");
builder.HasKey(w => w.WorkShiftId);
builder.Property(w => w.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200);
builder.Property(w => w.OtMultiplier).HasPrecision(6, 2);
builder.Property(w => w.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(w => w.CreatedAt).IsRequired();
builder.Property(w => w.RowVersion).IsRowVersion();
builder.HasIndex(w => w.Status);
}
}
@@ -89,6 +89,43 @@ public class ErpDbContext : DbContext
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet<Branch> Branches => Set<Branch>();
public DbSet<Department> Departments => Set<Department>();
public DbSet<Designation> Designations => Set<Designation>();
public DbSet<EmploymentType> EmploymentTypes => Set<EmploymentType>();
public DbSet<WorkShift> WorkShifts => Set<WorkShift>();
// --- HRM: employee core (docs/12-BACKEND-HRM.md Part C.2) ---
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeBankDetail> EmployeeBankDetails => Set<EmployeeBankDetail>();
// --- HRM: staff documents (docs/12-BACKEND-HRM.md Part C.3) ---
public DbSet<HrDocumentType> HrDocumentTypes => Set<HrDocumentType>();
public DbSet<EmployeeDocument> EmployeeDocuments => Set<EmployeeDocument>();
// --- HRM: attendance (docs/12-BACKEND-HRM.md Part C.4) ---
public DbSet<AttendanceUploadBatch> AttendanceUploadBatches => Set<AttendanceUploadBatch>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
// --- HRM: leave (docs/12-BACKEND-HRM.md Part C.5) ---
public DbSet<LeaveType> LeaveTypes => Set<LeaveType>();
public DbSet<LeaveRequest> LeaveRequests => Set<LeaveRequest>();
public DbSet<LeaveBalance> LeaveBalances => Set<LeaveBalance>();
// --- HRM: payroll (docs/12-BACKEND-HRM.md Part C.6) ---
public DbSet<SalaryComponent> SalaryComponents => Set<SalaryComponent>();
public DbSet<EmployeeSalaryStructure> EmployeeSalaryStructures => Set<EmployeeSalaryStructure>();
public DbSet<EmployeeSalaryStructureLine> EmployeeSalaryStructureLines => Set<EmployeeSalaryStructureLine>();
public DbSet<EmployeeLoan> EmployeeLoans => Set<EmployeeLoan>();
public DbSet<LoanInstallment> LoanInstallments => Set<LoanInstallment>();
public DbSet<PayrollStatutorySetting> PayrollStatutorySettings => Set<PayrollStatutorySetting>();
public DbSet<TaxSlab> TaxSlabs => Set<TaxSlab>();
public DbSet<PayrollRun> PayrollRuns => Set<PayrollRun>();
public DbSet<PayrollLine> PayrollLines => Set<PayrollLine>();
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
public DbSet<Payslip> Payslips => Set<Payslip>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -96,6 +133,29 @@ public class ErpDbContext : DbContext
// Pick up every IEntityTypeConfiguration in this assembly
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
// 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
// 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).
var utcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime, DateTime>(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
var nullableUtcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime?, DateTime?>(
v => v.HasValue ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) : v,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTime)) property.SetValueConverter(utcConverter);
else if (property.ClrType == typeof(DateTime?)) property.SetValueConverter(nullableUtcConverter);
}
}
}
// Audit trail (FR-X-02): capture mutations before save (accurate old→new), then
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
namespace ERPCore.Infra.Storage;
/// <summary>
/// File storage abstraction (docs/12-BACKEND-HRM.md A.1, C.10) — the first
/// attachment mechanism in this codebase. <see cref="LocalFileStorageService"/> is
/// the only implementation today; swapping to cloud blob storage later means
/// adding a new implementation + one DI registration change, no controller/service
/// change. Never exposes a path the client can dictate — callers pass only a
/// suggested filename, and get back a server-generated one.
/// </summary>
public interface IFileStorageService
{
/// <summary>Saves the stream under a server-generated name; returns the stored name, its relative path, and size.</summary>
Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync(
Stream content, string suggestedFileName, string contentType, CancellationToken ct = default);
Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default);
Task DeleteAsync(string relativePath, CancellationToken ct = default);
Task<bool> ExistsAsync(string relativePath, CancellationToken ct = default);
}
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Hosting;
namespace ERPCore.Infra.Storage;
/// <summary>
/// Disk-backed <see cref="IFileStorageService"/>. Writes under a configured root
/// OUTSIDE wwwroot (<c>FileStorage:RootPath</c>, default <c>App_Data/hr-documents</c>
/// relative to the content root) so files are never reachable via a static-file URL —
/// the only way to read one back is through an authenticated controller action that
/// streams via <see cref="OpenReadAsync"/> (docs/12-BACKEND-HRM.md A.1, §4).
/// </summary>
public sealed class LocalFileStorageService : IFileStorageService
{
private readonly string _root;
public LocalFileStorageService(IHostEnvironment env, IConfiguration configuration)
{
var configuredRoot = configuration["FileStorage:RootPath"] ?? "App_Data/hr-documents";
_root = Path.IsPathRooted(configuredRoot) ? configuredRoot : Path.Combine(env.ContentRootPath, configuredRoot);
Directory.CreateDirectory(_root);
}
public async Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync(
Stream content, string suggestedFileName, string contentType, CancellationToken ct = default)
{
var extension = Path.GetExtension(suggestedFileName);
var storedFileName = $"{Guid.NewGuid():N}{extension}";
// Bucket by year/month so a single directory never grows unbounded.
var subDir = Path.Combine(DateTime.UtcNow.Year.ToString(), DateTime.UtcNow.Month.ToString("00"));
var absoluteDir = Path.Combine(_root, subDir);
Directory.CreateDirectory(absoluteDir);
var relativePath = Path.Combine(subDir, storedFileName).Replace('\\', '/');
var absolutePath = Path.Combine(_root, relativePath);
await using (var fileStream = new FileStream(absolutePath, FileMode.CreateNew, FileAccess.Write))
{
await content.CopyToAsync(fileStream, ct);
}
var sizeBytes = new FileInfo(absolutePath).Length;
return (storedFileName, relativePath, sizeBytes);
}
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
{
var absolutePath = ResolveSafe(relativePath);
Stream stream = new FileStream(absolutePath, FileMode.Open, FileAccess.Read);
return Task.FromResult(stream);
}
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
{
var absolutePath = ResolveSafe(relativePath);
if (File.Exists(absolutePath)) File.Delete(absolutePath);
return Task.CompletedTask;
}
public Task<bool> ExistsAsync(string relativePath, CancellationToken ct = default)
{
var absolutePath = ResolveSafe(relativePath);
return Task.FromResult(File.Exists(absolutePath));
}
/// <summary>Resolves a stored relative path and rejects any attempt to escape the storage root.</summary>
private string ResolveSafe(string relativePath)
{
var absolutePath = Path.GetFullPath(Path.Combine(_root, relativePath));
var rootFull = Path.GetFullPath(_root);
if (!absolutePath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("Resolved path escapes the file storage root.");
return absolutePath;
}
}