This document provides a comprehensive guide for implementing the advanced rule engine features for overtime and holiday logic in the TimePlanning plugin.
- Database entities exist:
PayRuleSet,PayDayRule,PayDayTypeRule,PayTierRule,PayTimeBandRuleWorkingTimeRuleSet(with overtime fields:OvertimePeriodLengthDays,OvertimeAveragingWindowDays,MonthlyNormMode,OvertimeAllocationStrategy)BreakPolicy,BreakPolicyRulePlanRegistrationPayLine
- Helper methods in
PlanRegistrationHelper.cs:GetWorkIntervals()- Extract work time intervalsGetPauseIntervals()- Extract pause time intervalsCalculateTotalSeconds()- Sum interval durationsGetDayCode()- Classify day typeIsOfficialHoliday()- Check holiday statusComputeTimeTrackingFields()- Calculate work hoursMarkAsRuleEngineCalculated()- Mark as processed
- Break Policy Application Logic
- Pay Line Generation Logic
- Overtime Calculation Logic
- Holiday Paid-Off Logic
- Time Band Resolution Logic
- 11-Hour Rest Rule Validation
- API CRUD Endpoints for all rule entities
- Integration Tests
All new logic must be opt-in and gated behind configuration checks.
Example pattern:
// If new feature is not configured, use existing behavior
if (assignedSite.BreakPolicyId == null || breakPolicy == null)
{
// Existing behavior - no changes
return;
}
// New feature logic only runs when explicitly configured
ApplyBreakPolicy(planRegistration, breakPolicy);Each feature should be:
- Implemented in isolation
- Tested independently
- Integrated with existing code via opt-in flags
- Validated that existing tests still pass
All calculations use seconds internally to avoid floating-point errors:
long totalSeconds = intervals.Sum(i => (long)(i.End - i.Start).TotalSeconds);
double hours = totalSeconds / 3600.0; // Only convert to hours for displaySplit pause time into paid/unpaid breaks based on weekday-specific rules.
BreakPolicy (1) --> (M) BreakPolicyRule
- Id
- Name
- Description
BreakPolicyRule
- Id
- BreakPolicyId
- DayOfWeek (0-6: Sunday-Saturday)
- PaidBreakSeconds (per day)
- UnpaidBreakSeconds (per day)
Location: PlanRegistrationHelper.cs
/// <summary>
/// Apply break policy to split total pause time into paid and unpaid breaks.
/// Uses weekday-specific rules from BreakPolicy.
/// Backward compatible: Does nothing if break policy is null.
/// </summary>
/// <param name="planRegistration">The plan registration to update</param>
/// <param name="breakPolicy">The break policy with rules (null-safe)</param>
/// <param name="totalPauseSeconds">Total pause time in seconds</param>
public static void ApplyBreakPolicy(
PlanRegistration planRegistration,
BreakPolicy breakPolicy,
long totalPauseSeconds)
{
// Backward compatibility: If no break policy, do nothing
if (breakPolicy == null || breakPolicy.BreakPolicyRules == null)
{
planRegistration.TotalPauseHoursInSeconds = (int)totalPauseSeconds;
planRegistration.UnpaidBreakHoursInSeconds = 0;
planRegistration.PaidBreakHoursInSeconds = 0;
return;
}
// Find the rule for this day of week
var dayOfWeek = planRegistration.Date.DayOfWeek;
var rule = breakPolicy.BreakPolicyRules
.FirstOrDefault(r => r.DayOfWeek == (int)dayOfWeek);
if (rule == null)
{
// No rule for this day - treat all pause as unpaid (conservative)
planRegistration.TotalPauseHoursInSeconds = (int)totalPauseSeconds;
planRegistration.UnpaidBreakHoursInSeconds = (int)totalPauseSeconds;
planRegistration.PaidBreakHoursInSeconds = 0;
return;
}
// Apply the rule: allocate paid break first, rest is unpaid
var paidBreakSeconds = Math.Min(totalPauseSeconds, rule.PaidBreakSeconds);
var unpaidBreakSeconds = totalPauseSeconds - paidBreakSeconds;
planRegistration.TotalPauseHoursInSeconds = (int)totalPauseSeconds;
planRegistration.PaidBreakHoursInSeconds = (int)paidBreakSeconds;
planRegistration.UnpaidBreakHoursInSeconds = (int)unpaidBreakSeconds;
// Update PaidForExportSeconds (used for payroll export)
planRegistration.PaidForExportSeconds =
planRegistration.EffectiveNetHoursInSeconds +
planRegistration.PaidBreakHoursInSeconds;
}[Test]
public void ApplyBreakPolicy_WithNoPolicy_DoesNotModifyBreakFields()
{
// Arrange
var pr = new PlanRegistration { Date = new DateTime(2026, 1, 15) }; // Thursday
// Act
PlanRegistrationHelper.ApplyBreakPolicy(pr, null, 3600);
// Assert
Assert.That(pr.TotalPauseHoursInSeconds, Is.EqualTo(3600));
Assert.That(pr.PaidBreakHoursInSeconds, Is.EqualTo(0));
Assert.That(pr.UnpaidBreakHoursInSeconds, Is.EqualTo(0));
}
[Test]
public void ApplyBreakPolicy_WithPolicy_SplitsPaidAndUnpaid()
{
// Arrange
var pr = new PlanRegistration { Date = new DateTime(2026, 1, 15) }; // Thursday
var policy = new BreakPolicy
{
BreakPolicyRules = new List<BreakPolicyRule>
{
new BreakPolicyRule
{
DayOfWeek = 4, // Thursday
PaidBreakSeconds = 1800, // 30 minutes paid
UnpaidBreakSeconds = 0
}
}
};
// Act
PlanRegistrationHelper.ApplyBreakPolicy(pr, policy, 3600); // 60 min total
// Assert
Assert.That(pr.TotalPauseHoursInSeconds, Is.EqualTo(3600));
Assert.That(pr.PaidBreakHoursInSeconds, Is.EqualTo(1800)); // 30 min paid
Assert.That(pr.UnpaidBreakHoursInSeconds, Is.EqualTo(1800)); // 30 min unpaid
}Generate PlanRegistrationPayLine records based on PayRuleSet tier rules for different day types.
PayRuleSet (1) --> (M) PayDayRule (1) --> (M) PayTierRule
- Id - Id - Id
- Name - PayRuleSetId - PayDayRuleId
- Description - DayCode (SUNDAY, - Order (1, 2, 3...)
SATURDAY, WEEKDAY, - UpToSeconds
HOLIDAY, - PayCode (e.g. "SUN_80")
GRUNDLOVSDAG) - Description
Example: Sunday work
- DayCode: SUNDAY
- Tier 1: 0-39600s (11h) → PayCode "SUN_80" (80% rate)
- Tier 2: 39600s+ → PayCode "SUN_100" (100% premium)
/// <summary>
/// Generate pay lines for a plan registration based on pay rule set.
/// Splits paid hours across tiers defined in PayDayRule.
/// Backward compatible: Returns empty list if no pay rule set configured.
/// </summary>
/// <param name="planRegistrationId">The plan registration ID</param>
/// <param name="dayCode">Day classification (SUNDAY, SATURDAY, WEEKDAY, HOLIDAY, GRUNDLOVSDAG)</param>
/// <param name="paidForExportSeconds">Seconds to allocate across pay tiers</param>
/// <param name="payRuleSet">The pay rule set with day rules and tiers</param>
/// <param name="calculatedAtUtc">Timestamp of calculation</param>
/// <returns>List of pay lines to persist</returns>
public static List<PlanRegistrationPayLine> GeneratePayLines(
int planRegistrationId,
string dayCode,
long paidForExportSeconds,
PayRuleSet payRuleSet,
DateTime calculatedAtUtc)
{
var payLines = new List<PlanRegistrationPayLine>();
// Backward compatibility: If no pay rule set, return empty
if (payRuleSet == null || payRuleSet.PayDayRules == null)
{
return payLines;
}
// Find the day rule for this day code
var dayRule = payRuleSet.PayDayRules
.FirstOrDefault(r => r.DayCode == dayCode);
if (dayRule == null || dayRule.PayTierRules == null)
{
// No rule for this day type - no pay lines generated
return payLines;
}
// Sort tier rules by Order
var tiers = dayRule.PayTierRules
.Where(t => t.WorkflowState != Constants.WorkflowStates.Removed)
.OrderBy(t => t.Order)
.ToList();
if (!tiers.Any())
{
return payLines;
}
// Allocate paid seconds across tiers
long remainingSeconds = paidForExportSeconds;
long previousBoundary = 0;
foreach (var tier in tiers)
{
if (remainingSeconds <= 0)
{
break;
}
// Calculate tier capacity
long tierCapacity = tier.UpToSeconds.HasValue
? tier.UpToSeconds.Value - previousBoundary
: long.MaxValue; // Last tier has no upper limit
// Allocate to this tier
long allocatedSeconds = Math.Min(remainingSeconds, tierCapacity);
if (allocatedSeconds > 0)
{
payLines.Add(new PlanRegistrationPayLine
{
PlanRegistrationId = planRegistrationId,
PayCode = tier.PayCode,
Seconds = (int)allocatedSeconds,
Hours = allocatedSeconds / 3600.0,
CalculatedAtUtc = calculatedAtUtc,
TierOrder = tier.Order,
DayCode = dayCode,
CreatedAt = calculatedAtUtc,
UpdatedAt = calculatedAtUtc,
WorkflowState = Constants.WorkflowStates.Created
});
remainingSeconds -= allocatedSeconds;
}
if (tier.UpToSeconds.HasValue)
{
previousBoundary = tier.UpToSeconds.Value;
}
}
return payLines;
}[Test]
public void GeneratePayLines_Sunday14Hours_SplitsInto11And3Hours()
{
// Arrange
var payRuleSet = new PayRuleSet
{
PayDayRules = new List<PayDayRule>
{
new PayDayRule
{
DayCode = "SUNDAY",
PayTierRules = new List<PayTierRule>
{
new PayTierRule
{
Order = 1,
UpToSeconds = 39600, // 11 hours
PayCode = "SUN_80"
},
new PayTierRule
{
Order = 2,
UpToSeconds = null, // Unlimited
PayCode = "SUN_100"
}
}
}
}
};
// Act
var payLines = PlanRegistrationHelper.GeneratePayLines(
planRegistrationId: 123,
dayCode: "SUNDAY",
paidForExportSeconds: 50400, // 14 hours
payRuleSet: payRuleSet,
calculatedAtUtc: DateTime.UtcNow
);
// Assert
Assert.That(payLines.Count, Is.EqualTo(2));
Assert.That(payLines[0].PayCode, Is.EqualTo("SUN_80"));
Assert.That(payLines[0].Seconds, Is.EqualTo(39600)); // 11h
Assert.That(payLines[1].PayCode, Is.EqualTo("SUN_100"));
Assert.That(payLines[1].Seconds, Is.EqualTo(10800)); // 3h
}Map day types to different pay rate structures, supporting time-of-day bands.
PayRuleSet (1) --> (M) PayDayTypeRule (1) --> (M) PayTimeBandRule
- Id - Id - Id
- Name - PayRuleSetId - PayDayTypeRuleId
- DayType (Weekday, - StartTimeOfDay (HH:MM)
Saturday, Sunday, - EndTimeOfDay (HH:MM)
PublicHoliday, - PayCode
CompanyHoliday) - Order
/// <summary>
/// Resolve pay code based on day type and time bands.
/// Supports splitting a single day across multiple pay codes based on time of day.
/// Example: Sunday work before 18:00 = SUN_80, after 18:00 = SUN_100.
/// Backward compatible: Falls back to PayDayRule if no PayDayTypeRule exists.
/// </summary>
/// <param name="planRegistration">The plan registration</param>
/// <param name="payRuleSet">The pay rule set with type rules</param>
/// <returns>List of (PayCode, Seconds) tuples for each time band</returns>
public static List<(string PayCode, long Seconds)> ResolvePayCodesByTimeBand(
PlanRegistration planRegistration,
PayRuleSet payRuleSet)
{
var result = new List<(string, long)>();
// Determine day type
var dayType = GetDayType(planRegistration.Date);
// Find day type rule
var dayTypeRule = payRuleSet.PayDayTypeRules?
.FirstOrDefault(r => r.DayType == dayType);
if (dayTypeRule == null || dayTypeRule.PayTimeBandRules == null)
{
// Fallback to simple day code mapping
var dayCode = GetDayCode(planRegistration.Date);
var paidSeconds = planRegistration.PaidForExportSeconds;
return new List<(string, long)> { (dayCode, paidSeconds) };
}
// Get work intervals
var workIntervals = GetWorkIntervals(planRegistration);
// For each time band, calculate overlap with work intervals
var timeBands = dayTypeRule.PayTimeBandRules
.OrderBy(r => r.Order)
.ToList();
foreach (var band in timeBands)
{
var bandStart = TimeSpan.Parse(band.StartTimeOfDay);
var bandEnd = TimeSpan.Parse(band.EndTimeOfDay);
long bandSeconds = CalculateOverlap(workIntervals, bandStart, bandEnd);
if (bandSeconds > 0)
{
result.Add((band.PayCode, bandSeconds));
}
}
return result;
}
/// <summary>
/// Determine day type from date and holiday configuration.
/// </summary>
private static string GetDayType(DateTime date)
{
if (IsOfficialHoliday(date))
{
return "PublicHoliday";
}
return date.DayOfWeek switch
{
DayOfWeek.Sunday => "Sunday",
DayOfWeek.Saturday => "Saturday",
_ => "Weekday"
};
}
/// <summary>
/// Calculate overlap between work intervals and a time band.
/// </summary>
private static long CalculateOverlap(
IEnumerable<(DateTime Start, DateTime End)> workIntervals,
TimeSpan bandStart,
TimeSpan bandEnd)
{
long totalSeconds = 0;
foreach (var (start, end) in workIntervals)
{
var workStart = start.TimeOfDay;
var workEnd = end.TimeOfDay;
// Handle midnight crossing
if (workEnd < workStart)
{
workEnd = workEnd.Add(TimeSpan.FromDays(1));
}
if (bandEnd < bandStart)
{
bandEnd = bandEnd.Add(TimeSpan.FromDays(1));
}
// Calculate overlap
var overlapStart = workStart > bandStart ? workStart : bandStart;
var overlapEnd = workEnd < bandEnd ? workEnd : bandEnd;
if (overlapEnd > overlapStart)
{
totalSeconds += (long)(overlapEnd - overlapStart).TotalSeconds;
}
}
return totalSeconds;
}Calculate overtime based on configurable periods (weekly, bi-weekly, monthly) and allocation strategies.
WorkingTimeRuleSet
- Id
- Name
- OvertimePeriodLengthDays (7 = weekly, 14 = bi-weekly, null = monthly)
- OvertimeAveragingWindowDays (for rolling average)
- MonthlyNormMode (CalendarDays, WorkingDays)
- OvertimeAllocationStrategy (LatestFirst, EarliestFirst, Proportional)
- StandardHoursPerWeek (e.g., 37.0)
/// <summary>
/// Calculate overtime for a period of plan registrations.
/// Supports weekly, bi-weekly, monthly periods with configurable allocation strategies.
/// Backward compatible: Returns null if no working time rule set configured.
/// </summary>
/// <param name="planRegistrations">Plan registrations in the period</param>
/// <param name="workingTimeRuleSet">The working time rule set</param>
/// <returns>Dictionary of (PlanRegistrationId, OvertimeSeconds)</returns>
public static Dictionary<int, long> CalculateOvertime(
List<PlanRegistration> planRegistrations,
WorkingTimeRuleSet workingTimeRuleSet)
{
var result = new Dictionary<int, long>();
// Backward compatibility
if (workingTimeRuleSet == null ||
workingTimeRuleSet.OvertimePeriodLengthDays == null)
{
return result; // No overtime calculation
}
// Calculate period norm
var periodLengthDays = workingTimeRuleSet.OvertimePeriodLengthDays.Value;
var standardHoursPerWeek = workingTimeRuleSet.StandardHoursPerWeek ?? 37.0;
var periodNormSeconds = (long)((standardHoursPerWeek / 7.0) * periodLengthDays * 3600);
// Sum actual hours in period
var totalWorkedSeconds = planRegistrations
.Sum(pr => (long)pr.EffectiveNetHoursInSeconds);
// Calculate overtime
var overtimeSeconds = Math.Max(0, totalWorkedSeconds - periodNormSeconds);
if (overtimeSeconds == 0)
{
return result;
}
// Allocate overtime based on strategy
var strategy = workingTimeRuleSet.OvertimeAllocationStrategy ?? "LatestFirst";
switch (strategy)
{
case "LatestFirst":
result = AllocateOvertimeLatestFirst(planRegistrations, overtimeSeconds);
break;
case "EarliestFirst":
result = AllocateOvertimeEarliestFirst(planRegistrations, overtimeSeconds);
break;
case "Proportional":
result = AllocateOvertimeProportional(planRegistrations, overtimeSeconds, totalWorkedSeconds);
break;
default:
throw new InvalidOperationException($"Unknown allocation strategy: {strategy}");
}
return result;
}
private static Dictionary<int, long> AllocateOvertimeLatestFirst(
List<PlanRegistration> planRegistrations,
long overtimeSeconds)
{
var result = new Dictionary<int, long>();
var sorted = planRegistrations.OrderByDescending(pr => pr.Date).ToList();
long remaining = overtimeSeconds;
foreach (var pr in sorted)
{
if (remaining <= 0) break;
var allocated = Math.Min(remaining, pr.EffectiveNetHoursInSeconds);
result[pr.Id] = allocated;
remaining -= allocated;
}
return result;
}
private static Dictionary<int, long> AllocateOvertimeEarliestFirst(
List<PlanRegistration> planRegistrations,
long overtimeSeconds)
{
var result = new Dictionary<int, long>();
var sorted = planRegistrations.OrderBy(pr => pr.Date).ToList();
long remaining = overtimeSeconds;
foreach (var pr in sorted)
{
if (remaining <= 0) break;
var allocated = Math.Min(remaining, pr.EffectiveNetHoursInSeconds);
result[pr.Id] = allocated;
remaining -= allocated;
}
return result;
}
private static Dictionary<int, long> AllocateOvertimeProportional(
List<PlanRegistration> planRegistrations,
long overtimeSeconds,
long totalWorkedSeconds)
{
var result = new Dictionary<int, long>();
foreach (var pr in planRegistrations)
{
var proportion = (double)pr.EffectiveNetHoursInSeconds / totalWorkedSeconds;
var allocated = (long)(overtimeSeconds * proportion);
result[pr.Id] = allocated;
}
return result;
}Orchestrate all calculations and persist to database in a single transaction.
/// <summary>
/// Recalculate and persist all rule engine outputs for a plan registration.
/// This is the main orchestration method that coordinates all calculations.
/// Backward compatible: Falls back to simple calculation if no rules configured.
/// </summary>
/// <param name="planRegistration">The plan registration to process</param>
/// <param name="dbContext">Database context</param>
/// <param name="assignedSite">The assigned site with rule references</param>
public static async Task RecalculateAndPersistAsync(
PlanRegistration planRegistration,
TimePlanningPnDbContext dbContext,
AssignedSite assignedSite)
{
// Step 1: Compute basic time tracking fields
ComputeTimeTrackingFields(planRegistration);
// Step 2: Apply break policy (if configured)
if (assignedSite.BreakPolicyId.HasValue)
{
var breakPolicy = await dbContext.BreakPolicies
.Include(bp => bp.BreakPolicyRules)
.FirstOrDefaultAsync(bp => bp.Id == assignedSite.BreakPolicyId.Value);
if (breakPolicy != null)
{
ApplyBreakPolicy(
planRegistration,
breakPolicy,
planRegistration.TotalPauseHoursInSeconds);
}
}
// Step 3: Generate pay lines (if configured)
if (assignedSite.PayRuleSetId.HasValue)
{
var payRuleSet = await dbContext.PayRuleSets
.Include(prs => prs.PayDayRules)
.ThenInclude(pdr => pdr.PayTierRules)
.Include(prs => prs.PayDayTypeRules)
.ThenInclude(pdtr => pdtr.PayTimeBandRules)
.FirstOrDefaultAsync(prs => prs.Id == assignedSite.PayRuleSetId.Value);
if (payRuleSet != null)
{
// Delete existing pay lines
var existingLines = await dbContext.PlanRegistrationPayLines
.Where(l => l.PlanRegistrationId == planRegistration.Id)
.ToListAsync();
foreach (var line in existingLines)
{
await line.Delete(dbContext);
}
// Generate new pay lines
var dayCode = GetDayCode(planRegistration.Date);
var newLines = GeneratePayLines(
planRegistration.Id,
dayCode,
planRegistration.PaidForExportSeconds,
payRuleSet,
DateTime.UtcNow);
foreach (var line in newLines)
{
await line.Create(dbContext);
}
// Store applied rule set IDs
planRegistration.PayRuleSetId = assignedSite.PayRuleSetId;
planRegistration.BreakPolicyId = assignedSite.BreakPolicyId;
}
}
// Step 4: Mark as calculated
MarkAsRuleEngineCalculated(planRegistration);
// Step 5: Save changes
await planRegistration.Update(dbContext);
}using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microting.eFormApi.BasePn.Infrastructure.Models.API;
using TimePlanning.Pn.Abstractions;
using TimePlanning.Pn.Infrastructure.Models.PayRules;
namespace TimePlanning.Pn.Controllers
{
[Authorize]
[Route("api/time-planning-pn/pay-rule-sets")]
public class PayRuleSetsController : Controller
{
private readonly IPayRuleSetsService _payRuleSetsService;
public PayRuleSetsController(IPayRuleSetsService payRuleSetsService)
{
_payRuleSetsService = payRuleSetsService;
}
[HttpGet]
public async Task<OperationDataResult<PayRuleSetsListModel>> Index(
[FromQuery] PayRuleSetsRequestModel requestModel)
{
return await _payRuleSetsService.Index(requestModel);
}
[HttpGet("{id}")]
public async Task<OperationDataResult<PayRuleSetModel>> Read(int id)
{
return await _payRuleSetsService.Read(id);
}
[HttpPost]
public async Task<OperationResult> Create([FromBody] PayRuleSetCreateModel model)
{
return await _payRuleSetsService.Create(model);
}
[HttpPut("{id}")]
public async Task<OperationResult> Update(int id, [FromBody] PayRuleSetUpdateModel model)
{
return await _payRuleSetsService.Update(id, model);
}
[HttpDelete("{id}")]
public async Task<OperationResult> Delete(int id)
{
return await _payRuleSetsService.Delete(id);
}
}
}public interface IPayRuleSetsService
{
Task<OperationDataResult<PayRuleSetsListModel>> Index(PayRuleSetsRequestModel requestModel);
Task<OperationDataResult<PayRuleSetModel>> Read(int id);
Task<OperationResult> Create(PayRuleSetCreateModel model);
Task<OperationResult> Update(int id, PayRuleSetUpdateModel model);
Task<OperationResult> Delete(int id);
}public class PayRuleSetsService : IPayRuleSetsService
{
private readonly TimePlanningPnDbContext _dbContext;
private readonly ILogger<PayRuleSetsService> _logger;
public PayRuleSetsService(
TimePlanningPnDbContext dbContext,
ILogger<PayRuleSetsService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<OperationDataResult<PayRuleSetsListModel>> Index(
PayRuleSetsRequestModel requestModel)
{
try
{
var query = _dbContext.PayRuleSets
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed);
var total = await query.CountAsync();
var ruleSets = await query
.Skip(requestModel.Offset)
.Take(requestModel.PageSize)
.Select(prs => new PayRuleSetSimpleModel
{
Id = prs.Id,
Name = prs.Name,
Description = prs.Description
})
.ToListAsync();
return new OperationDataResult<PayRuleSetsListModel>(
true,
new PayRuleSetsListModel
{
Total = total,
PayRuleSets = ruleSets
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pay rule sets");
return new OperationDataResult<PayRuleSetsListModel>(
false,
$"Error: {ex.Message}");
}
}
public async Task<OperationDataResult<PayRuleSetModel>> Read(int id)
{
try
{
var payRuleSet = await _dbContext.PayRuleSets
.Include(prs => prs.PayDayRules)
.ThenInclude(pdr => pdr.PayTierRules)
.FirstOrDefaultAsync(prs => prs.Id == id);
if (payRuleSet == null)
{
return new OperationDataResult<PayRuleSetModel>(
false,
"Pay rule set not found");
}
var model = new PayRuleSetModel
{
Id = payRuleSet.Id,
Name = payRuleSet.Name,
Description = payRuleSet.Description,
PayDayRules = payRuleSet.PayDayRules?.Select(pdr => new PayDayRuleModel
{
Id = pdr.Id,
DayCode = pdr.DayCode,
PayTierRules = pdr.PayTierRules?.Select(ptr => new PayTierRuleModel
{
Id = ptr.Id,
Order = ptr.Order,
UpToSeconds = ptr.UpToSeconds,
PayCode = ptr.PayCode,
Description = ptr.Description
}).ToList()
}).ToList()
};
return new OperationDataResult<PayRuleSetModel>(true, model);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error reading pay rule set {id}");
return new OperationDataResult<PayRuleSetModel>(
false,
$"Error: {ex.Message}");
}
}
public async Task<OperationResult> Create(PayRuleSetCreateModel model)
{
try
{
var payRuleSet = new PayRuleSet
{
Name = model.Name,
Description = model.Description,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
WorkflowState = Constants.WorkflowStates.Created
};
await payRuleSet.Create(_dbContext);
return new OperationResult(true, "Pay rule set created successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating pay rule set");
return new OperationResult(false, $"Error: {ex.Message}");
}
}
public async Task<OperationResult> Update(int id, PayRuleSetUpdateModel model)
{
try
{
var payRuleSet = await _dbContext.PayRuleSets
.FirstOrDefaultAsync(prs => prs.Id == id);
if (payRuleSet == null)
{
return new OperationResult(false, "Pay rule set not found");
}
payRuleSet.Name = model.Name;
payRuleSet.Description = model.Description;
payRuleSet.UpdatedAt = DateTime.UtcNow;
await payRuleSet.Update(_dbContext);
return new OperationResult(true, "Pay rule set updated successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error updating pay rule set {id}");
return new OperationResult(false, $"Error: {ex.Message}");
}
}
public async Task<OperationResult> Delete(int id)
{
try
{
var payRuleSet = await _dbContext.PayRuleSets
.FirstOrDefaultAsync(prs => prs.Id == id);
if (payRuleSet == null)
{
return new OperationResult(false, "Pay rule set not found");
}
await payRuleSet.Delete(_dbContext);
return new OperationResult(true, "Pay rule set deleted successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error deleting pay rule set {id}");
return new OperationResult(false, $"Error: {ex.Message}");
}
}
}using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microting.TimePlanningBase.Infrastructure.Data.Entities;
using NSubstitute;
using NUnit.Framework;
using TimePlanning.Pn.Services;
namespace TimePlanning.Pn.Test
{
[TestFixture]
public class PayRuleSetsServiceTests : TestBaseSetup
{
private PayRuleSetsService _service;
[SetUp]
public async Task Setup()
{
await base.SetUp();
_service = new PayRuleSetsService(
TimePlanningPnDbContext,
Substitute.For<ILogger<PayRuleSetsService>>());
}
[Test]
public async Task Create_ValidModel_CreatesPayRuleSet()
{
// Arrange
var model = new PayRuleSetCreateModel
{
Name = "Test Rule Set",
Description = "Test description"
};
// Act
var result = await _service.Create(model);
// Assert
Assert.That(result.Success, Is.True);
var created = await TimePlanningPnDbContext.PayRuleSets
.FirstOrDefaultAsync(prs => prs.Name == "Test Rule Set");
Assert.That(created, Is.Not.Null);
Assert.That(created.Description, Is.EqualTo("Test description"));
}
[Test]
public async Task Read_ExistingId_ReturnsPayRuleSet()
{
// Arrange
var payRuleSet = new PayRuleSet
{
Name = "Test Rule Set",
Description = "Test description"
};
await payRuleSet.Create(TimePlanningPnDbContext);
// Act
var result = await _service.Read(payRuleSet.Id);
// Assert
Assert.That(result.Success, Is.True);
Assert.That(result.Model.Name, Is.EqualTo("Test Rule Set"));
}
[Test]
public async Task Update_ExistingId_UpdatesPayRuleSet()
{
// Arrange
var payRuleSet = new PayRuleSet
{
Name = "Original Name",
Description = "Original description"
};
await payRuleSet.Create(TimePlanningPnDbContext);
var updateModel = new PayRuleSetUpdateModel
{
Name = "Updated Name",
Description = "Updated description"
};
// Act
var result = await _service.Update(payRuleSet.Id, updateModel);
// Assert
Assert.That(result.Success, Is.True);
var updated = await TimePlanningPnDbContext.PayRuleSets
.FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id);
Assert.That(updated.Name, Is.EqualTo("Updated Name"));
Assert.That(updated.Description, Is.EqualTo("Updated description"));
}
[Test]
public async Task Delete_ExistingId_SoftDeletesPayRuleSet()
{
// Arrange
var payRuleSet = new PayRuleSet
{
Name = "Test Rule Set"
};
await payRuleSet.Create(TimePlanningPnDbContext);
// Act
var result = await _service.Delete(payRuleSet.Id);
// Assert
Assert.That(result.Success, Is.True);
var deleted = await TimePlanningPnDbContext.PayRuleSets
.FirstOrDefaultAsync(prs => prs.Id == payRuleSet.Id);
Assert.That(deleted.WorkflowState, Is.EqualTo(Constants.WorkflowStates.Removed));
}
}
}-
Phase 1: Break Policy (2-3 hours)
- Implement
ApplyBreakPolicy()method - Add unit tests
- Verify backward compatibility
- Implement
-
Phase 2: Pay Line Generation (3-4 hours)
- Implement
GeneratePayLines()method - Add unit tests for tier splitting
- Test Sunday 14h → 11h + 3h scenario
- Implement
-
Phase 3: Day Type & Time Band Resolution (2-3 hours)
- Implement
ResolvePayCodesByTimeBand()method - Add tests for time band overlaps
- Test Sunday late night shifts
- Implement
-
Phase 4: Overtime Calculation (4-5 hours)
- Implement
CalculateOvertime()method - Implement allocation strategies
- Add tests for weekly, bi-weekly, monthly periods
- Implement
-
Phase 5: Orchestration (2-3 hours)
- Implement
RecalculateAndPersistAsync()method - Add integration tests with database
- Verify transactional behavior
- Implement
-
Phase 6: API Endpoints (8-10 hours)
- Implement PayRuleSetsController + Service
- Implement PayDayTypeRulesController + Service
- Implement PayTimeBandRulesController + Service
- Implement WorkingTimeRuleSetsController + Service
- Implement BreakPolicyController + Service
- Add integration tests for all endpoints
-
Phase 7: Integration (3-4 hours)
- Update TimePlanningWorkingHoursService
- Update TimePlanningPlanningService
- Add end-to-end tests
-
Phase 8: Security & Documentation (2-3 hours)
- Run code_review
- Run codeql_checker
- Add inline documentation
- Update IMPLEMENTATION_STATUS.md
Total Estimated Effort: 26-35 hours
- Test each calculation method independently
- Use reflection to test private methods
- Mock database entities, not database itself
- Fast execution (< 100ms per test)
- Test with real database (Testcontainers)
- Verify persistence and transactional behavior
- Test API endpoints with full request/response cycle
- Use NSubstitute for external dependencies
- Create "golden" tests that assert existing behavior unchanged
- Run existing test suite after each change
- Verify null/default configurations fall back to current behavior
- Benchmark batch calculation for 600-900 registrations
- Ensure no N+1 queries
- Profile memory usage
Before releasing each feature:
- Existing tests still pass unchanged
- New logic only activates when explicitly configured
- Default behavior matches current production behavior
- API changes are additive only (no breaking changes)
- Database migrations are reversible
- Feature can be disabled via configuration
- Performance regression tests pass
This guide provides a comprehensive roadmap for implementing the advanced rule engine. Each feature is designed to be:
- Backward compatible: Existing behavior preserved by default
- Incremental: Can be implemented and tested independently
- Well-tested: Full unit and integration test coverage
- Documented: Clear examples and patterns
Follow the implementation order, ensure tests pass after each step, and maintain backward compatibility throughout.