How every metric is computed, scheduled, cached, and how auto-trigger salary upgrades work.
Status: Implemented as
modules/KpiMatrix/libraries/Kpi_calculation_engine.php— June 2026 reconciliation. Reconciliation notes:
- The engine computes 29 metrics (not 30); identifiers use
metric_key(e.g.att_rate,ticket_resolved) — see the authoritative list in 02_KPI_CATEGORIES_AND_METRICS.md Appendix A.- Public entry points:
process_cycle($cycle_id),compute_all_metrics($assignment_id, $start, $end),compute_composite($assignment_id),compute_single_metric(...). Calculators are registered in$metric_registrykeyed bymetric_key.- Normalization is within-band linear interpolation — bands are matched directly against the authored threshold ranges (no inversion/relabelling); for
lowermetrics only the within-band interpolation is reversed. Scoring now lives inModules\KpiMatrix\Services\ScoreNormalizer, and the engine delegates_normalize_score()/_get_rating_band()to it. See doc 02 §4.- Scheduling/cron is implemented via
modules/KpiMatrix/jobs/AutoCalculateKpiCyclesJob.php(slugauto_calculate_kpi_cycles, nightly30 1 * * *). It runsprocess_cycle()foropencycles flaggedauto_calculate=1whoseperiod_endhas passed. Manual triggering viaKpi_matrix::trigger_calculation()remains. The cache layer in §14 is still aspirational.- Several fidelity fixes from doc 02 Appendix C (C-1 ticket rate, C-3 manual-entry scoping, C-5 salary pricing, C-6 timesheet table) landed in June 2026.
Table of Contents
- Architecture Overview
- Metric Computation Registry
- Category 1: Attendance & Punctuality
- Category 2: Task Performance
- Category 3: Project Delivery
- Category 4: Ticket Resolution
- Category 5: Time Management
- Category 6: Compliance & Conduct
- Category 7: Professional Development
- Category 8: Collaboration & Peer Review
- Scoring & Normalization Pipeline
- Composite Score Aggregation
- Scheduling & Automation (Cron)
- Caching Strategy
- Auto-Trigger Salary Upgrade Engine
- Error Handling & Missing Data
- Performance Optimization
- Calculation Audit Trail
1. Architecture Overview
┌──────────────────────────────────────────────────────────┐
│ CALCULATION ENGINE │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Data │ │ Metric │ │ Composite │ │
│ │ Collectors │──│ Calculators │──│ Aggregator │ │
│ │ (15 models) │ │ (29 metrics) │ │ (weighted sum) │ │
│ └─────────────┘ └──────────────┘ └────────┬───────┘ │
│ │ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────▼───────┐ │
│ │ Score │ │ Salary │ │ Result │ │
│ │ Normalizer │──│ Auto-Trigger │──│ Writer │ │
│ │ (1.00-5.00) │ │ Engine │ │ (DB + history) │ │
│ └─────────────┘ └──────────────┘ └────────────────┘ │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Scheduler │ │ Cache Layer │ │ Audit Logger │ │
│ │ (Cron) │ │ (phpfastcache│ │ (system_logs) │ │
│ └─────────────┘ └──────────────┘ └────────────────┘ │
└──────────────────────────────────────────────────────────┘
Processing Pipeline (Per Employee)
1. COLLECT → Fetch raw data from source models for review period
2. COMPUTE → Apply metric formula to produce raw_value
3. NORMALIZE → Map raw_value to 1.00–5.00 score via threshold bands
4. WEIGHT → Multiply score × metric_weight
5. AGGREGATE → Sum weighted scores per category, then overall
6. RECOMMEND → If salary auto-trigger enabled, generate salary recommendation
7. PERSIST → Write to kpi_metric_scores, kpi_composite_results, kpi_score_history
8. NOTIFY → Send notifications to reviewer/employee
Key Design Principles
| Principle | Implementation |
|---|---|
| Idempotent | Re-running calculation for same employee+cycle produces identical results |
| Auditable | Every score includes computation_log JSON with raw values and formula |
| Configurable | Thresholds stored in kpi_template_metrics.scoring_thresholds JSON |
| Fault-tolerant | Missing data results in NULL score, not failure |
| Batch-capable | Process all employees in a cycle via cron or manual trigger |
2. Metric Computation Registry
Each of the 29 metrics has a registered computation method. The engine uses a strategy pattern — $metric_registry maps metric_key to a calculator method (and the models it needs).
Registry Structure
// In Kpi_calculation_engine.php (library)
private $metric_registry = array(
// Category 1: Attendance
'att_rate' => array('method' => '_calc_attendance_rate', 'models' => array('Attendance_model')),
'punctuality_rate' => array('method' => '_calc_punctuality_rate', 'models' => array('Attendance_model')),
'absence_rate' => array('method' => '_calc_absence_rate', 'models' => array('Payslips_model')),
'overtime_ratio' => array('method' => '_calc_overtime_ratio', 'models' => array('Attendance_model')),
// Category 2: Task Performance
'task_completion' => array('method' => '_calc_task_completion_rate', 'models' => array('Tasks_model')),
'task_ontime' => array('method' => '_calc_task_ontime_rate', 'models' => array('Tasks_model')),
'task_quality' => array('method' => '_calc_task_quality_score', 'models' => array('Tasks_model')),
'task_throughput' => array('method' => '_calc_task_throughput', 'models' => array('Tasks_model')),
// Category 3: Project Delivery
'proj_completion' => array('method' => '_calc_project_completion', 'models' => array('Projects_model')),
'proj_ontime' => array('method' => '_calc_project_ontime_rate', 'models' => array('Projects_model')),
'proj_contribution' => array('method' => '_calc_project_contribution', 'models' => array('Projects_model', 'Tasks_model')),
// Category 4: Ticket Resolution
'ticket_resolved' => array('method' => '_calc_tickets_resolved', 'models' => array('Tickets_model')),
'ticket_response' => array('method' => '_calc_ticket_response_time', 'models' => array('Tickets_model')),
'ticket_resolution' => array('method' => '_calc_ticket_resolution_time','models' => array('Tickets_model')),
'ticket_satisfaction'=> array('method' => '_calc_ticket_satisfaction', 'models' => array('Tickets_model')),
'ticket_reopen' => array('method' => '_calc_ticket_reopen_rate', 'models' => array('Tickets_model')),
// Category 5: Time Management
'timesheet_util' => array('method' => '_calc_timesheet_utilization', 'models' => array('Timesheets_model')),
'timesheet_accuracy'=> array('method' => '_calc_timesheet_accuracy', 'models' => array('Timesheets_model')),
'schedule_adherence'=> array('method' => '_calc_schedule_adherence', 'models' => array('Attendance_model', 'Schedule_model')),
// Category 6: Compliance & Conduct
'discipline_score' => array('method' => '_calc_discipline_score', 'models' => array('Discipline_entries_model')),
'leave_compliance' => array('method' => '_calc_leave_compliance', 'models' => array('Leave_applications_model')),
'policy_adherence' => array('method' => '_calc_policy_adherence', 'models' => array('Discipline_entries_model')),
// Category 7: Professional Development
'cert_count' => array('method' => '_calc_certification_count', 'models' => array('Certifications_model')),
'skill_growth' => array('method' => '_calc_skill_growth', 'models' => array('Skillsets_workforce_model')),
'training_hours' => array('method' => '_calc_training_hours', 'models' => array('Timesheets_model')),
'training_completion'=> array('method' => '_calc_training_completion', 'models' => array('Kpi_manual_entries_model')),
// Category 8: Collaboration
'peer_review_avg' => array('method' => '_calc_peer_review_avg', 'models' => array('Kpi_manual_entries_model')),
'cross_dept_collab' => array('method' => '_calc_cross_dept_collab', 'models' => array('Projects_model', 'Tasks_model')),
'knowledge_sharing' => array('method' => '_calc_knowledge_sharing', 'models' => array('Kpi_manual_entries_model')),
'mentoring_score' => array('method' => '_calc_mentoring_score', 'models' => array('Kpi_manual_entries_model')),
);
Automation Classification
| Type | Count | Metrics | Data Source |
|---|---|---|---|
| Fully Automated | 23 | att_rate, punctuality_rate, absence_rate, overtime_ratio, task_completion, task_ontime, task_throughput, proj_completion, proj_ontime, proj_contribution, ticket_resolved, ticket_response, ticket_resolution, ticket_reopen, timesheet_util, timesheet_accuracy, schedule_adherence, discipline_score, leave_compliance, policy_adherence, cert_count, skill_growth, cross_dept_collab | Existing DB models |
| Semi-Automated | 3 | task_quality, ticket_satisfaction, training_hours | Existing data + manual supplement |
| Manual Entry | 4 | training_completion, peer_review_avg, knowledge_sharing, mentoring_score | kpi_manual_entries table |
3. Category 1: Attendance & Punctuality
3.1 Attendance Rate (att_rate)
Formula: (days_present / total_working_days) × 100
/**
* Calculate attendance rate for a user within a date range.
*
* @param int $user_id
* @param string $start_date Y-m-d
* @param string $end_date Y-m-d
* @return array ['raw_value' => float, 'computation_log' => array]
*/
protected function _calc_attendance_rate($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Attendance_model');
// Count distinct dates with clock-in within range
$attendance_table = $ci->db->dbprefix('attendance');
$sql = "SELECT COUNT(DISTINCT DATE(in_time)) as days_present
FROM $attendance_table
WHERE user_id = ?
AND deleted = 0
AND DATE(in_time) BETWEEN ? AND ?
AND status = 'approved'";
$result = $ci->db->query($sql, array($user_id, $start_date, $end_date))->row();
$days_present = (int) ($result->days_present ?? 0);
// Calculate total working days (exclude weekends + holidays)
$total_working_days = $this->_get_working_days($start_date, $end_date, $user_id);
$raw_value = ($total_working_days > 0)
? round(($days_present / $total_working_days) * 100, 2)
: null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'days_present' => $days_present,
'total_working_days'=> $total_working_days,
'formula' => '(days_present / total_working_days) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
3.2 Punctuality Rate (punctuality_rate)
Formula: (on_time_arrivals / total_attendance_days) × 100
protected function _calc_punctuality_rate($user_id, $start_date, $end_date)
{
$ci = get_instance();
$attendance_table = $ci->db->dbprefix('attendance');
$metrics_table = $ci->db->dbprefix('attendance_metrics');
// Count days where late = 0 (from attendance_metrics)
$sql = "SELECT
COUNT(DISTINCT DATE(a.in_time)) as total_days,
SUM(CASE WHEN COALESCE(m.late, 0) = 0 THEN 1 ELSE 0 END) as on_time_days
FROM $attendance_table a
LEFT JOIN $metrics_table m ON m.attendance_id = a.id
WHERE a.user_id = ?
AND a.deleted = 0
AND DATE(a.in_time) BETWEEN ? AND ?
AND a.status = 'approved'";
$result = $ci->db->query($sql, array($user_id, $start_date, $end_date))->row();
$total_days = (int) ($result->total_days ?? 0);
$on_time_days = (int) ($result->on_time_days ?? 0);
$raw_value = ($total_days > 0)
? round(($on_time_days / $total_days) * 100, 2)
: null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'on_time_days' => $on_time_days,
'total_days' => $total_days,
'formula' => '(on_time_days / total_attendance_days) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
3.3 Absence Rate (absence_rate)
Formula: (absent_days / total_working_days) × 100
protected function _calc_absence_rate($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Payslips_model');
// Get absent hours from approved payslips covering the period
$year = date('Y', strtotime($start_date));
$absent_total_hours = $ci->Payslips_model->get_yearly_absent_total($user_id, $year);
$absent_days = round($absent_total_hours / 8, 2); // Hours ÷ standard 8-hour day
$total_working_days = $this->_get_working_days($start_date, $end_date, $user_id);
$raw_value = ($total_working_days > 0)
? round(($absent_days / $total_working_days) * 100, 2)
: null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'absent_hours' => $absent_total_hours,
'absent_days' => $absent_days,
'total_working_days' => $total_working_days,
'formula' => '(absent_days / total_working_days) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
3.4 Overtime Ratio (overtime_ratio)
Formula: (overtime_hours / regular_hours) × 100
protected function _calc_overtime_ratio($user_id, $start_date, $end_date)
{
$ci = get_instance();
$attendance_table = $ci->db->dbprefix('attendance');
$metrics_table = $ci->db->dbprefix('attendance_metrics');
$sql = "SELECT
COALESCE(SUM(m.regular_hours), 0) as regular_hours,
COALESCE(SUM(m.overtime), 0) as overtime_hours
FROM $attendance_table a
LEFT JOIN $metrics_table m ON m.attendance_id = a.id
WHERE a.user_id = ?
AND a.deleted = 0
AND DATE(a.in_time) BETWEEN ? AND ?
AND a.status = 'approved'";
$result = $ci->db->query($sql, array($user_id, $start_date, $end_date))->row();
$regular = (float) ($result->regular_hours ?? 0);
$overtime = (float) ($result->overtime_hours ?? 0);
$raw_value = ($regular > 0) ? round(($overtime / $regular) * 100, 2) : null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'regular_hours' => $regular,
'overtime_hours' => $overtime,
'formula' => '(overtime_hours / regular_hours) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
4. Category 2: Task Performance
4.1 Task Completion Rate (task_completion)
Formula: (completed_tasks / total_assigned_tasks) × 100
protected function _calc_task_completion_rate($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Tasks_model');
$stats = $ci->Tasks_model->get_task_statistics(array(
'assigned_to' => $user_id,
'start_date' => $start_date,
'end_date' => $end_date
));
// status_id: 1=To Do, 2=In Progress, 3=Done/Completed
$total_tasks = 0;
$completed_tasks = 0;
foreach ($stats as $stat) {
$total_tasks += (int) $stat->total;
if ((int) $stat->status_id === 3) {
$completed_tasks += (int) $stat->total;
}
}
$raw_value = ($total_tasks > 0)
? round(($completed_tasks / $total_tasks) * 100, 2)
: null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'completed_tasks' => $completed_tasks,
'total_tasks' => $total_tasks,
'formula' => '(completed_tasks / total_assigned_tasks) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
4.2 Task On-Time Delivery Rate (task_ontime)
Formula: (tasks_completed_by_deadline / total_completed_tasks) × 100
protected function _calc_task_ontime_rate($user_id, $start_date, $end_date)
{
$ci = get_instance();
$tasks_table = $ci->db->dbprefix('tasks');
// Tasks completed within the review period
$sql = "SELECT
COUNT(*) as total_completed,
SUM(CASE
WHEN deadline IS NOT NULL AND DATE(updated_at) <= DATE(deadline) THEN 1
ELSE 0
END) as on_time_completed
FROM $tasks_table
WHERE FIND_IN_SET(?, assigned_to)
AND status_id = 3
AND deleted = 0
AND DATE(updated_at) BETWEEN ? AND ?";
$result = $ci->db->query($sql, array($user_id, $start_date, $end_date))->row();
$total = (int) ($result->total_completed ?? 0);
$on_time = (int) ($result->on_time_completed ?? 0);
$raw_value = ($total > 0) ? round(($on_time / $total) * 100, 2) : null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'on_time_completed' => $on_time,
'total_completed' => $total,
'formula' => '(on_time / total_completed) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
4.3 Task Quality Score (task_quality) — Semi-Automated
Formula: (tasks_without_rework / total_completed_tasks) × 100
Rework is detected by tasks that were marked complete then reopened (status changed from done back to in-progress). If rework tracking is not available, falls back to manual entry.
protected function _calc_task_quality_score($user_id, $start_date, $end_date)
{
$ci = get_instance();
$tasks_table = $ci->db->dbprefix('tasks');
$activity_table = $ci->db->dbprefix('activity_logs');
// Check if rework tracking is available (activity logs record status changes)
$total_completed_sql = "SELECT COUNT(*) as cnt FROM $tasks_table
WHERE FIND_IN_SET(?, assigned_to) AND status_id = 3
AND deleted = 0 AND DATE(updated_at) BETWEEN ? AND ?";
$total = (int) $ci->db->query($total_completed_sql, array($user_id, $start_date, $end_date))->row()->cnt;
// Count tasks that were reopened (status went 3→2 then back to 3)
$rework_sql = "SELECT COUNT(DISTINCT t.id) as rework_count
FROM $tasks_table t
INNER JOIN $activity_table al ON al.log_for_id = t.id
AND al.log_for = 'task' AND al.action = 'reopened'
WHERE FIND_IN_SET(?, t.assigned_to) AND t.status_id = 3
AND t.deleted = 0 AND DATE(t.updated_at) BETWEEN ? AND ?";
$rework = (int) $ci->db->query($rework_sql, array($user_id, $start_date, $end_date))->row()->rework_count;
// Fallback to manual entry if activity logs don't track reopening
if ($total == 0) {
return $this->_get_manual_entry_score($user_id, 'task_quality', $start_date, $end_date);
}
$no_rework = max(0, $total - $rework);
$raw_value = round(($no_rework / $total) * 100, 2);
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'total_completed' => $total,
'rework_count' => $rework,
'no_rework_count' => $no_rework,
'formula' => '(tasks_without_rework / total_completed) × 100',
'source' => 'automated',
'calculated_at' => get_current_utc_time()
)
);
}
4.4 Task Throughput (task_throughput)
Formula: tasks_completed_per_month (averaged over review period)
protected function _calc_task_throughput($user_id, $start_date, $end_date)
{
$ci = get_instance();
$tasks_table = $ci->db->dbprefix('tasks');
$sql = "SELECT COUNT(*) as total_completed
FROM $tasks_table
WHERE FIND_IN_SET(?, assigned_to)
AND status_id = 3 AND deleted = 0
AND DATE(updated_at) BETWEEN ? AND ?";
$total = (int) $ci->db->query($sql, array($user_id, $start_date, $end_date))->row()->total_completed;
// Calculate months in range
$months = max(1, $this->_get_months_in_range($start_date, $end_date));
$raw_value = round($total / $months, 2);
return array(
'raw_value' => $raw_value,
'unit' => 'count_per_month',
'computation_log' => array(
'total_completed' => $total,
'months_in_range' => $months,
'formula' => 'total_completed / months',
'calculated_at' => get_current_utc_time()
)
);
}
5. Category 3: Project Delivery
5.1 Project Completion Rate (proj_completion)
Formula: (completed_points / total_points) × 100 (using Projects_model point system)
protected function _calc_project_completion($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Projects_model');
// Get projects where user is a member, active during review period
$projects = $ci->Projects_model->get_details(array(
'team_member_id' => $user_id,
'start_date' => $start_date,
'deadline' => $end_date
))->result();
$total_points = 0;
$completed_points = 0;
$project_details = array();
foreach ($projects as $project) {
$total_points += (float) ($project->total_points ?? 0);
$completed_points += (float) ($project->completed_points ?? 0);
$project_details[] = array(
'id' => $project->id,
'title' => $project->title,
'total_points' => $project->total_points,
'completed_points'=> $project->completed_points
);
}
$raw_value = ($total_points > 0)
? round(($completed_points / $total_points) * 100, 2)
: null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'total_points' => $total_points,
'completed_points' => $completed_points,
'project_count' => count($projects),
'projects' => $project_details,
'formula' => '(completed_points / total_points) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
5.2 Project On-Time Delivery (proj_ontime)
Formula: (projects_completed_by_deadline / total_completed_projects) × 100
5.3 Project Contribution Score (proj_contribution)
Formula: (user_completed_task_points / total_project_task_points) × 100
Measures how much of the project workload the employee handled. Uses task points within projects.
6. Category 4: Ticket Resolution
6.1 Tickets Resolved (ticket_resolved)
Formula: COUNT of tickets closed within review period
protected function _calc_tickets_resolved($user_id, $start_date, $end_date)
{
$ci = get_instance();
$tickets_table = $ci->db->dbprefix('tickets');
$sql = "SELECT COUNT(*) as resolved_count
FROM $tickets_table
WHERE assigned_to = ?
AND deleted = 0
AND status = 'closed'
AND DATE(closed_date) BETWEEN ? AND ?";
$count = (int) $ci->db->query($sql, array($user_id, $start_date, $end_date))->row()->resolved_count;
return array(
'raw_value' => $count,
'unit' => 'count',
'computation_log' => array(
'resolved_count' => $count,
'formula' => 'COUNT(closed tickets in period)',
'calculated_at' => get_current_utc_time()
)
);
}
6.2 Average Response Time (ticket_response)
Formula: AVG(first_response_timestamp - created_timestamp) in hours
6.3 Average Resolution Time (ticket_resolution)
Formula: AVG(closed_timestamp - created_timestamp) in hours
6.4 Ticket Satisfaction (ticket_satisfaction) — Semi-Automated
Formula: Average rating from feedback if available, else manual entry.
6.5 Ticket Reopen Rate (ticket_reopen)
Formula: (reopened_tickets / total_resolved_tickets) × 100
7. Category 5: Time Management
7.1 Timesheet Utilization (timesheet_util)
Formula: (logged_hours / expected_hours) × 100
protected function _calc_timesheet_utilization($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Timesheets_model');
$summary = $ci->Timesheets_model->get_summary_details(array(
'user_id' => $user_id,
'start_date' => $start_date,
'end_date' => $end_date,
'group_by' => 'member'
))->row();
$logged_hours = (float) ($summary->total_duration ?? 0) / 3600; // seconds → hours
$working_days = $this->_get_working_days($start_date, $end_date, $user_id);
$hours_per_day = (float) (get_user_option('hours_per_day', $user_id) ?: 8);
$expected_hours = $working_days * $hours_per_day;
$raw_value = ($expected_hours > 0)
? round(($logged_hours / $expected_hours) * 100, 2)
: null;
return array(
'raw_value' => $raw_value,
'unit' => 'percentage',
'computation_log' => array(
'logged_hours' => round($logged_hours, 2),
'expected_hours' => $expected_hours,
'working_days' => $working_days,
'hours_per_day' => $hours_per_day,
'formula' => '(logged_hours / expected_hours) × 100',
'calculated_at' => get_current_utc_time()
)
);
}
7.2 Timesheet Accuracy (timesheet_accuracy)
Formula: 1 - ABS(logged_hours - attendance_hours) / attendance_hours
Compares timesheet entries to actual clock-in/out hours to detect inflation or under-reporting.
7.3 Schedule Adherence (schedule_adherence)
Formula: (days_following_schedule / total_scheduled_days) × 100
Checks if employee clocked in/out within tolerance of their assigned schedule.
8. Category 6: Compliance & Conduct
8.1 Discipline Score (discipline_score)
Formula: MAX(5.00 - (weighted_severity_points)) — starts at 5.00, deducted per incident.
protected function _calc_discipline_score($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Discipline_entries_model');
$entries_table = $ci->db->dbprefix('discipline_entries');
// Severity weights: verbal=0.25, written=0.50, suspension=1.00, termination=2.50
$sql = "SELECT
COUNT(*) as total_entries,
SUM(CASE severity
WHEN 'verbal_warning' THEN 0.25
WHEN 'written_warning' THEN 0.50
WHEN 'suspension' THEN 1.00
WHEN 'termination' THEN 2.50
ELSE 0.25
END) as weighted_points
FROM $entries_table
WHERE FIND_IN_SET(?, employee_ids)
AND deleted = 0
AND DATE(date_created) BETWEEN ? AND ?";
$result = $ci->db->query($sql, array($user_id, $start_date, $end_date))->row();
$weighted_points = (float) ($result->weighted_points ?? 0);
$raw_value = max(1.00, round(5.00 - $weighted_points, 2));
return array(
'raw_value' => $raw_value,
'unit' => 'score',
'computation_log' => array(
'total_entries' => (int) ($result->total_entries ?? 0),
'weighted_points' => $weighted_points,
'formula' => 'MAX(1.00, 5.00 - weighted_severity_points)',
'severity_weights' => array(
'verbal_warning' => 0.25,
'written_warning' => 0.50,
'suspension' => 1.00,
'termination' => 2.50
),
'calculated_at' => get_current_utc_time()
)
);
}
8.2 Leave Compliance (leave_compliance)
Formula: (approved_leaves / total_leave_applications) × 100
Measures whether employee follows proper leave request procedures (approved vs. absent without leave).
8.3 Policy Adherence (policy_adherence)
Formula: Composite of discipline_score + leave_compliance weighted 60/40.
9. Category 7: Professional Development
9.1 Certification Count (cert_count)
Formula: COUNT of active (non-expired) certifications
protected function _calc_certification_count($user_id, $start_date, $end_date)
{
$ci = get_instance();
$ci->load->model('Certifications_model');
$total = $ci->Certifications_model->get_total_certifications_of_user($user_id);
return array(
'raw_value' => (int) $total,
'unit' => 'count',
'computation_log' => array(
'active_certifications' => (int) $total,
'formula' => 'COUNT(active, non-expired certifications)',
'calculated_at' => get_current_utc_time()
)
);
}
9.2 Skill Growth (skill_growth)
Formula: (new_skills_in_period / previous_skill_count) × 100
9.3 Training Hours (training_hours)
Formula: Hours logged to training-tagged projects/tasks in timesheets.
9.4 Training Completion (training_completion) — Manual
Formula: Manual entry by supervisor. Score 1.00–5.00.
10. Category 8: Collaboration & Peer Review
All four metrics in this category require manual entry via the kpi_manual_entries table:
| Metric | Entry By | Frequency |
|---|---|---|
peer_review_avg |
Peers (anonymous) | Per review cycle |
cross_dept_collab |
Automated + manager override | Per review cycle |
knowledge_sharing |
Manager assessment | Per review cycle |
mentoring_score |
Manager assessment | Per review cycle |
Manual Entry Retrieval Pattern
protected function _get_manual_entry_score($user_id, $metric_code, $start_date, $end_date)
{
$ci = get_instance();
$manual_table = $ci->db->dbprefix('kpi_manual_entries');
$sql = "SELECT AVG(score) as avg_score, COUNT(*) as entry_count
FROM $manual_table
WHERE user_id = ?
AND metric_code = ?
AND status = 'approved'
AND deleted = 0
AND entry_date BETWEEN ? AND ?";
$result = $ci->db->query($sql, array($user_id, $metric_code, $start_date, $end_date))->row();
$raw_value = ($result->entry_count > 0) ? round((float) $result->avg_score, 2) : null;
return array(
'raw_value' => $raw_value,
'unit' => 'score',
'computation_log' => array(
'avg_score' => $raw_value,
'entry_count' => (int) $result->entry_count,
'source' => 'manual_entry',
'formula' => 'AVG(approved manual entries)',
'calculated_at'=> get_current_utc_time()
)
);
}
11. Scoring & Normalization Pipeline
Step 1: Raw Value → Threshold Lookup
Each metric stores its scoring thresholds as JSON in kpi_template_metrics.scoring_thresholds:
{
"outstanding": { "min": 98, "max": 100 },
"exceeds": { "min": 95, "max": 97.99 },
"meets": { "min": 90, "max": 94.99 },
"needs_improvement": { "min": 80, "max": 89.99 },
"unsatisfactory": { "min": 0, "max": 79.99 }
}
Step 2: Band → Score Mapping
Scoring lives in the pure, DB-free service Modules\KpiMatrix\Services\ScoreNormalizer; the engine delegates _normalize_score() (and _get_rating_band()) to it. The algorithm matches the raw value directly against the authored threshold ranges — bands are never relabelled or inverted. For direction = 'lower' metrics, only the within-band interpolation is reversed (a lower raw value earns a higher score within its band); the bands themselves are not swapped. Out-of-range raw values are clamped into the authored global [min, max] range before matching, so a value above the top band scores as the extreme band that owns that end of the scale (there is no "above outstanding.max → 5.00" shortcut).
/**
* Normalize a raw value to a 1.00–5.00 score using scoring thresholds.
* Delegates to Modules\KpiMatrix\Services\ScoreNormalizer.
*
* @param float|null $raw_value
* @param array $thresholds Decoded JSON from kpi_template_metrics
* @param string $direction 'higher' (bigger is better) or 'lower' (smaller is better)
* @return float|null Score between 1.00 and 5.00, or null if raw_value is null
*/
protected function _normalize_score($raw_value, $thresholds, $direction = 'higher')
{
return $this->score_normalizer->normalize($raw_value, (array) $thresholds, $direction);
}
ScoreNormalizer::normalize() implements the shipped algorithm:
public function normalize($rawValue, array $thresholds, $direction = 'higher')
{
if ($rawValue === null) {
return null;
}
$raw = (float) $rawValue;
// Fixed band → score window (base .. ceiling), best → worst.
// (self::BAND_SCORES: outstanding 4.50–5.00, exceeds 3.50–4.49,
// meets 2.50–3.49, needs_improvement 1.50–2.49, unsatisfactory 1.00–1.49)
// Collect the bands that carry a numeric range, preserving best→worst order.
$defined = array();
foreach (self::BAND_SCORES as $band => $window) {
if (isset($thresholds[$band]['min']) && isset($thresholds[$band]['max'])) {
$defined[$band] = array(
'min' => (float) $thresholds[$band]['min'],
'max' => (float) $thresholds[$band]['max'],
);
}
}
if (!$defined) {
return null; // nothing to score against
}
$lower = ($direction === 'lower');
// Clamp the raw value into the authored global range so an out-of-range
// value scores as the extreme band that OWNS that end of the scale
// (no "above max → 5.00" shortcut; a bell-curve / inverted-metric outlier
// lands in its worst band instead).
$globalMin = min(array_map(function ($r) { return $r['min']; }, $defined));
$globalMax = max(array_map(function ($r) { return $r['max']; }, $defined));
$raw = max($globalMin, min($globalMax, $raw));
// Match the band whose [min,max] contains the (clamped) raw value —
// authored ranges are used DIRECTLY, no inversion/relabelling.
foreach ($defined as $band => $r) {
if ($raw >= $r['min'] && $raw <= $r['max']) {
return $this->interpolate($raw, $r['min'], $r['max'], $band, $lower);
}
}
// (Contiguous configs never fall through; a rare interior micro-gap
// snaps to the nearest band by distance — see the service source.)
}
/**
* Interpolate a raw value within a matched band's score window.
* For 'lower' metrics the position is INVERTED so the low end of the raw
* range earns the high end of the score window — the ONLY place direction
* changes anything; bands are not swapped.
*/
private function interpolate($raw, $min, $max, $band, $lower)
{
$window = self::BAND_SCORES[$band];
$width = max(0.01, $max - $min);
$position = $lower ? (($max - $raw) / $width) : (($raw - $min) / $width);
$position = max(0.0, min(1.0, $position));
$score = $window['base'] + ($position * ($window['ceiling'] - $window['base']));
return round(min(5.00, max(1.00, $score)), 2);
}
Note (June 2026 engine audit): the previous engine handled
lowermetrics by relabelling band ranges (moving theunsatisfactoryrange under theoutstandingkey, etc.), which inverted results —0%absence scored1.00. That relabelling step was deleted; bands are now matched directly and only within-band interpolation is reversed forlowermetrics. The old "above the top band →5.00" fallback (which rewarded pathological inputs) was replaced by the clamp-to-global-range behaviour above.
12. Composite Score Aggregation
Two-Level Weighted Average
Composite Score = Σ (category_weight × category_score)
Where:
category_score = Σ (metric_weight × metric_score) / Σ (metric_weight)
Note: Only non-null metric scores contribute to the sum.
If all metrics in a category are null, the category is excluded
and its weight is redistributed proportionally.
Implementation
/**
* Compute composite score for an employee assignment.
*
* @param int $assignment_id kpi_employee_assignments.id
* @return array ['composite_score' => float, 'rating_band' => string, 'category_scores' => array]
*/
public function compute_composite($assignment_id)
{
$ci = get_instance();
$ci->load->model('Kpi_metric_scores_model');
$assignment = $ci->Kpi_employee_assignments_model->get_one($assignment_id);
$template_id = $assignment->template_id;
// Get all template metrics with weights
$metrics = $ci->Kpi_template_metrics_model->get_details(array(
'template_id' => $template_id
))->result();
// Get all computed scores for this assignment
$scores = $ci->Kpi_metric_scores_model->get_details(array(
'assignment_id' => $assignment_id
))->result();
// Index scores by metric_code
$score_map = array();
foreach ($scores as $s) {
$score_map[$s->metric_code] = $s;
}
// Group metrics by category
$categories = array();
foreach ($metrics as $metric) {
$cat = $metric->category;
if (!isset($categories[$cat])) {
$categories[$cat] = array(
'category_weight' => (float) $metric->category_weight,
'metrics' => array(),
'weighted_sum' => 0,
'weight_total' => 0
);
}
$score_row = $score_map[$metric->metric_code] ?? null;
$score_value = $score_row ? (float) $score_row->normalized_score : null;
$categories[$cat]['metrics'][] = array(
'code' => $metric->metric_code,
'weight' => (float) $metric->weight,
'score' => $score_value
);
if ($score_value !== null) {
$categories[$cat]['weighted_sum'] += $score_value * (float) $metric->weight;
$categories[$cat]['weight_total'] += (float) $metric->weight;
}
}
// Calculate category scores and composite
$composite_weighted_sum = 0;
$composite_weight_total = 0;
$category_scores = array();
foreach ($categories as $cat_name => $cat_data) {
if ($cat_data['weight_total'] > 0) {
$cat_score = round($cat_data['weighted_sum'] / $cat_data['weight_total'], 2);
$category_scores[$cat_name] = $cat_score;
$composite_weighted_sum += $cat_score * $cat_data['category_weight'];
$composite_weight_total += $cat_data['category_weight'];
}
}
$composite_score = ($composite_weight_total > 0)
? round($composite_weighted_sum / $composite_weight_total, 2)
: null;
// Determine rating band
$rating_band = $this->_get_rating_band($composite_score);
return array(
'composite_score' => $composite_score,
'rating_band' => $rating_band,
'category_scores' => $category_scores,
'metrics_computed' => count($score_map),
'metrics_total' => count($metrics),
'metrics_null' => count($metrics) - count(array_filter($score_map, function($s) { return $s->normalized_score !== null; }))
);
}
/**
* Map composite score to rating band.
*/
protected function _get_rating_band($score)
{
if ($score === null) return null;
if ($score >= 4.50) return 'outstanding';
if ($score >= 3.50) return 'exceeds_expectations';
if ($score >= 2.50) return 'meets_expectations';
if ($score >= 2.00) return 'needs_improvement';
return 'unsatisfactory';
}
13. Scheduling & Automation (Cron)
Cron Job Integration
Automated calculation ships as an Advanced Cron job — modules/KpiMatrix/jobs/AutoCalculateKpiCyclesJob.php (namespace Modules\KpiMatrix\Jobs), discovered by the module JobRegistry. It does not hook into ERPat's legacy monthly-jobs cron library under application/libraries/.
| Property | Value |
|---|---|
| Slug | auto_calculate_kpi_cycles |
| Runtime | Advanced Cron (php erpat cron:tick / cron:run auto_calculate_kpi_cycles) |
| Schedule | 30 1 * * * (daily, 01:30) |
| Selects | open review cycles flagged auto_calculate = 1 whose period_end has passed |
| Action | Calls Kpi_calculation_engine::process_cycle() for each matching cycle |
// modules/KpiMatrix/jobs/AutoCalculateKpiCyclesJob.php (shape)
namespace Modules\KpiMatrix\Jobs;
class AutoCalculateKpiCyclesJob
{
// slug: auto_calculate_kpi_cycles, schedule: 30 1 * * *
public function handle()
{
$ci = get_instance();
$ci->load->library('Kpi_calculation_engine');
$ci->load->model('Kpi_review_cycles_model');
// Only 'open' cycles flagged for auto-calculation whose period has ended.
$today = date('Y-m-d');
$cycles = $ci->Kpi_review_cycles_model->get_details(array(
'status' => 'open',
'auto_calculate' => 1,
))->result();
foreach ($cycles as $cycle) {
if ($cycle->period_end < $today) {
$ci->Kpi_calculation_engine->process_cycle($cycle->id);
}
}
}
}
Manual triggering via Kpi_matrix::trigger_calculation() remains available for on-demand runs.
Cycle Processing Flow
/**
* Process all employees in a KPI review cycle.
*
* @param int $cycle_id
* @return array Summary of processing results
*/
public function process_cycle($cycle_id)
{
$ci = get_instance();
$ci->load->model('Kpi_employee_assignments_model');
$ci->load->model('Kpi_review_cycles_model');
// Update cycle status to 'calculating'
$ci->Kpi_review_cycles_model->save(array('status' => 'calculating'), $cycle_id);
$cycle = $ci->Kpi_review_cycles_model->get_one($cycle_id);
$assignments = $ci->Kpi_employee_assignments_model->get_details(array(
'cycle_id' => $cycle_id
))->result();
$results = array(
'total' => count($assignments),
'success' => 0,
'failed' => 0,
'skipped' => 0,
'errors' => array()
);
foreach ($assignments as $assignment) {
try {
// Skip if already finalized
if ($assignment->status === 'finalized') {
$results['skipped']++;
continue;
}
// Step 1: Compute all metric scores
$this->compute_all_metrics($assignment->id, $cycle->period_start, $cycle->period_end);
// Step 2: Compute composite score
$composite = $this->compute_composite($assignment->id);
// Step 3: Save composite result
$this->_save_composite_result($assignment->id, $composite);
// Step 4: Generate salary recommendation if auto-trigger enabled
if ($this->_is_salary_auto_trigger_enabled()) {
$this->_generate_salary_recommendation($assignment, $composite);
}
// Step 5: Snapshot to history
$this->_save_score_history($assignment->id, $composite);
$results['success']++;
} catch (Exception $e) {
$results['failed']++;
$results['errors'][] = array(
'assignment_id' => $assignment->id,
'user_id' => $assignment->user_id,
'error' => $e->getMessage()
);
log_message('error', 'KPI Calculation Error for assignment ' . $assignment->id . ': ' . $e->getMessage());
}
}
// Update cycle status to 'review' if all processed
if ($results['failed'] === 0) {
$ci->Kpi_review_cycles_model->save(array('status' => 'review'), $cycle_id);
}
// Log activity
set_system_logs(
'KPI Cycle #' . $cycle_id . ' processed: ' . $results['success'] . ' success, ' . $results['failed'] . ' failed',
json_encode($results),
'calculate', 'kpi_review_cycle',
array('user_id' => 0, 'remarks' => 'cron_job'),
$cycle_id
);
return $results;
}
Scheduling Frequency
| Job Type | Frequency | Purpose |
|---|---|---|
Auto-Calculate Cycles (auto_calculate_kpi_cycles) |
Daily 30 1 * * * |
Process open cycles flagged auto_calculate = 1 whose period_end has passed |
| Composite Rebuild | On-demand | Recompute composite after manual entries |
| Salary Recommendation | On cycle finalization | Generate salary step recommendations |
| Cache Warm | Daily (aspirational — see §14) | Pre-compute metric snapshots for dashboard |
| History Snapshot | On cycle finalization | Immutable audit trail creation |
14. Caching Strategy
Cache Keys & TTL
// phpfastcache integration via existing ERPat cache infrastructure
$cache_keys = array(
// Per-employee metric cache (refreshed monthly or on-demand)
'kpi_metric_{user_id}_{metric_code}_{cycle_id}' => array(
'ttl' => 86400, // 24 hours
'tags' => array('kpi', 'kpi_user_{user_id}', 'kpi_cycle_{cycle_id}')
),
// Composite score cache (refreshed after any metric change)
'kpi_composite_{user_id}_{cycle_id}' => array(
'ttl' => 86400,
'tags' => array('kpi', 'kpi_user_{user_id}', 'kpi_cycle_{cycle_id}')
),
// Department summary (refreshed daily)
'kpi_dept_summary_{dept_id}_{cycle_id}' => array(
'ttl' => 86400,
'tags' => array('kpi', 'kpi_dept_{dept_id}', 'kpi_cycle_{cycle_id}')
),
// Template metrics list (refreshed on template change)
'kpi_template_metrics_{template_id}' => array(
'ttl' => 604800, // 7 days
'tags' => array('kpi', 'kpi_template_{template_id}')
)
);
Cache Invalidation Triggers
| Event | Keys Invalidated |
|---|---|
| Manual entry submitted/approved | kpi_metric_{user_id}_*, kpi_composite_{user_id}_* |
| Attendance approved | kpi_metric_{user_id}_att_*, kpi_metric_{user_id}_punctuality_* |
| Task completed | kpi_metric_{user_id}_task_* |
| Template weights changed | kpi_template_metrics_{template_id}, all composites using template |
| Cycle status changed | kpi_cycle_{cycle_id} tagged entries |
15. Auto-Trigger Salary Upgrade Engine
This is the core integration between KPI scores and automatic salary progression. Controlled by a toggle in Settings > Component > Salary Options.
15.1 Settings Configuration
Setting Key: kpi_salary_auto_trigger
Location: Settings > Component > Salary Options
Default: disabled (0)
Values: 0 = disabled, 1 = enabled
Additional settings under Salary Options:
| Setting Key | Default | Description |
|---|---|---|
kpi_salary_auto_trigger |
0 |
Master toggle: Enable auto salary upgrade from KPI |
kpi_salary_require_approval |
1 |
Require HR approval before applying salary change |
kpi_salary_auto_apply |
0 |
Apply immediately without approval (dangerous, admin-only) |
kpi_salary_min_cycles |
1 |
Minimum consecutive review cycles before triggering |
kpi_salary_effective_date_mode |
next_period |
When upgrade takes effect: immediately, next_period, next_month |
kpi_salary_max_step_increase |
2 |
Maximum step increase per review cycle |
kpi_salary_downgrade_enabled |
0 |
Allow step decrease for poor performance |
15.2 Settings UI (Salary Options Component)
Add to application/views/settings/components/salary_options.php:
<!-- KPI-Based Salary Progression -->
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<i class="fa fa-line-chart"></i>
<?php echo lang('kpi_salary_integration'); ?>
</h4>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3"><?php echo lang('kpi_salary_auto_trigger'); ?></label>
<div class="col-md-9">
<?php echo form_dropdown(
'kpi_salary_auto_trigger',
array('0' => lang('disabled'), '1' => lang('enabled')),
get_setting('kpi_salary_auto_trigger', '0'),
'class="select2"'
); ?>
<span class="help-block"><?php echo lang('kpi_salary_auto_trigger_help'); ?></span>
</div>
</div>
<!-- Conditional fields shown when auto_trigger = 1 -->
<div id="kpi_salary_options" style="display:none;">
<div class="form-group">
<label class="col-md-3"><?php echo lang('kpi_salary_require_approval'); ?></label>
<div class="col-md-9">
<?php echo form_dropdown(
'kpi_salary_require_approval',
array('1' => lang('yes'), '0' => lang('no')),
get_setting('kpi_salary_require_approval', '1'),
'class="select2"'
); ?>
</div>
</div>
<div class="form-group">
<label class="col-md-3"><?php echo lang('kpi_salary_effective_date_mode'); ?></label>
<div class="col-md-9">
<?php echo form_dropdown(
'kpi_salary_effective_date_mode',
array(
'immediately' => lang('immediately'),
'next_period' => lang('next_pay_period'),
'next_month' => lang('next_month')
),
get_setting('kpi_salary_effective_date_mode', 'next_period'),
'class="select2"'
); ?>
</div>
</div>
<div class="form-group">
<label class="col-md-3"><?php echo lang('kpi_salary_max_step_increase'); ?></label>
<div class="col-md-9">
<?php echo form_input(array(
'name' => 'kpi_salary_max_step_increase',
'type' => 'number',
'value' => get_setting('kpi_salary_max_step_increase', '2'),
'min' => '0',
'max' => '5',
'class' => 'form-control'
)); ?>
</div>
</div>
<div class="form-group">
<label class="col-md-3"><?php echo lang('kpi_salary_min_cycles'); ?></label>
<div class="col-md-9">
<?php echo form_input(array(
'name' => 'kpi_salary_min_cycles',
'type' => 'number',
'value' => get_setting('kpi_salary_min_cycles', '1'),
'min' => '1',
'max' => '4',
'class' => 'form-control'
)); ?>
<span class="help-block"><?php echo lang('kpi_salary_min_cycles_help'); ?></span>
</div>
</div>
<div class="form-group">
<label class="col-md-3"><?php echo lang('kpi_salary_downgrade_enabled'); ?></label>
<div class="col-md-9">
<?php echo form_dropdown(
'kpi_salary_downgrade_enabled',
array('0' => lang('disabled'), '1' => lang('enabled')),
get_setting('kpi_salary_downgrade_enabled', '0'),
'class="select2"'
); ?>
<span class="help-block text-danger"><?php echo lang('kpi_salary_downgrade_warning'); ?></span>
</div>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function() {
function toggleKpiSalaryOptions() {
var enabled = $('[name="kpi_salary_auto_trigger"]').val() === '1';
$('#kpi_salary_options').toggle(enabled);
}
$('[name="kpi_salary_auto_trigger"]').on('change', toggleKpiSalaryOptions);
toggleKpiSalaryOptions();
});
</script>
15.3 Auto-Trigger Engine Flow
Cycle Finalized
│
▼
Check: kpi_salary_auto_trigger === '1'?
│ No → Stop
│ Yes ↓
▼
For each employee in cycle:
│
├─ Get composite_score & rating_band
├─ Get current salary grade + step
├─ Look up rating→step mapping
├─ Check consecutive cycles (min_cycles requirement)
├─ Apply max_step_increase cap
│
├─ If step_change > 0:
│ ├─ Compute new monthly salary from grade matrix
│ ├─ Check at_ceiling constraint
│ │
│ ├─ If kpi_salary_require_approval = 1:
│ │ └─ Create salary_history record with status='draft'
│ │ + Create kpi_salary_recommendations record
│ │ + Notify HR/Admin for approval
│ │
│ └─ If kpi_salary_auto_apply = 1:
│ └─ Create salary_history record with status='approved'
│ + Trigger sync_to_job_info()
│ + Notify employee of salary update
│
└─ If step_change = 0 or negative:
└─ Log recommendation (no action)
15.4 Core Auto-Trigger Method
/**
* Generate and optionally apply salary upgrade based on KPI score.
* Called from process_cycle() when auto-trigger is enabled.
*
* @param object $assignment kpi_employee_assignments row
* @param array $composite Output from compute_composite()
*/
protected function _generate_salary_recommendation($assignment, $composite)
{
$ci = get_instance();
$ci->load->helper('payroll');
$ci->load->model('Salary_history_model');
$ci->load->model('Salary_grades_model');
$user_id = $assignment->user_id;
$rating_band = $composite['rating_band'];
// Step 1: Get current salary info
$current = get_current_salary($user_id);
$grade_info = get_salary_grade($user_id);
if (!$grade_info || !$grade_info['grade_id']) {
// No salary grade assigned — cannot auto-trigger
$this->_save_salary_recommendation($assignment->id, array(
'status' => 'skipped',
'reason' => 'no_salary_grade_assigned',
'current_step' => null,
'new_step' => null
));
return;
}
// Step 2: Get rating→step mapping from settings
$mapping_json = get_setting('kpi_rating_step_mapping', '{}');
$mapping = json_decode($mapping_json, true);
$step_change = (int) ($mapping[$rating_band]['step_change'] ?? 0);
$requires_pip = (bool) ($mapping[$rating_band]['requires_pip'] ?? false);
// Step 3: Check min_cycles requirement
$min_cycles = (int) get_setting('kpi_salary_min_cycles', 1);
if ($min_cycles > 1) {
$consecutive = $this->_get_consecutive_rating_count($user_id, $rating_band);
if ($consecutive < $min_cycles) {
$this->_save_salary_recommendation($assignment->id, array(
'status' => 'deferred',
'reason' => 'min_cycles_not_met',
'required' => $min_cycles,
'consecutive' => $consecutive,
'rating_band' => $rating_band
));
return;
}
}
// Step 4: Apply max step cap
$max_increase = (int) get_setting('kpi_salary_max_step_increase', 2);
$step_change = min($step_change, $max_increase);
// Step 5: Compute new step & salary
$current_step = (int) ($grade_info['step'] ?? 1);
$total_steps = (int) $grade_info['total_steps'];
$new_step = min($current_step + $step_change, $total_steps);
if ($new_step === $current_step && $step_change > 0) {
$this->_save_salary_recommendation($assignment->id, array(
'status' => 'at_ceiling',
'reason' => 'already_at_max_step',
'current_step' => $current_step,
'grade_name' => $grade_info['grade_name']
));
return;
}
// Step 6: Calculate new salary from grade matrix
$new_salary = $ci->Salary_grades_model->compute_salary_for_step(
$grade_info['grade_id'], $new_step
);
// Step 7: Determine effective date
$effective_mode = get_setting('kpi_salary_effective_date_mode', 'next_period');
$effective_date = $this->_resolve_effective_date($effective_mode);
// Step 8: Create salary_history record
$auto_apply = (bool) get_setting('kpi_salary_auto_apply', 0);
$require_approval = (bool) get_setting('kpi_salary_require_approval', 1);
$salary_data = array(
'user_id' => $user_id,
'effective_date' => $effective_date,
'payroll_basis' => $current['payroll_basis'] ?? 'salary_based',
'salary_term' => $current['salary_term'] ?? 'regular',
'salary_grade_id' => $grade_info['grade_id'],
'salary_step' => $new_step,
'monthly_salary' => $new_salary,
'hours_per_day' => $current['hours_per_day'] ?? 8,
'days_per_year' => $current['days_per_year'] ?? 261,
'reason' => 'KPI Auto-Upgrade: ' . ucfirst(str_replace('_', ' ', $rating_band))
. ' (Score: ' . $composite['composite_score'] . ')',
'status' => ($auto_apply && !$require_approval) ? 'approved' : 'draft',
'created_by' => 0 // System-generated
);
// Calculate daily/hourly rates
$salary_data['daily_rate'] = round(($new_salary * 12) / $salary_data['days_per_year'], 2);
$salary_data['hourly_rate'] = round($salary_data['daily_rate'] / $salary_data['hours_per_day'], 2);
$save_id = $ci->Salary_history_model->save($salary_data);
// If auto-apply: immediately sync to job_info
if ($save_id && $salary_data['status'] === 'approved') {
$ci->Salary_history_model->sync_to_job_info($user_id);
// Notify employee
log_notification($user_id, 'salary_upgraded_kpi', array(
'new_salary' => to_currency($new_salary),
'new_step' => $new_step,
'grade' => $grade_info['grade_name'],
'effective' => format_to_date($effective_date)
));
}
// Save recommendation record
$this->_save_salary_recommendation($assignment->id, array(
'status' => ($salary_data['status'] === 'approved') ? 'applied' : 'pending_approval',
'salary_history_id'=> $save_id,
'current_step' => $current_step,
'new_step' => $new_step,
'current_salary' => $current['monthly_salary'],
'new_salary' => $new_salary,
'step_change' => $step_change,
'rating_band' => $rating_band,
'composite_score' => $composite['composite_score'],
'effective_date' => $effective_date,
'requires_pip' => $requires_pip
));
// System log
set_system_logs(
'KPI Salary Auto-Trigger: ' . get_user_legal_name($user_id, false)
. ' Step ' . $current_step . ' → ' . $new_step
. ' (' . to_currency($new_salary) . ')',
json_encode($salary_data),
'create', 'kpi_salary_recommendation',
array('user_id' => 0, 'remarks' => 'auto_trigger'),
$save_id
);
}
15.5 Effective Date Resolution
protected function _resolve_effective_date($mode)
{
switch ($mode) {
case 'immediately':
return date('Y-m-d');
case 'next_period':
// Next 1st or 16th (common semi-monthly cutoffs in PH)
$day = (int) date('d');
if ($day < 16) {
return date('Y-m-16');
} else {
return date('Y-m-01', strtotime('+1 month'));
}
case 'next_month':
return date('Y-m-01', strtotime('+1 month'));
default:
return date('Y-m-01', strtotime('+1 month'));
}
}
15.6 Salary Grade + Salary History Integration Points
┌──────────────────────────────────────────────────────┐
│ KPI Composite Result │
│ (rating_band = 'exceeds_expectations') │
└────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ kpi_rating_step_mapping (settings) │
│ exceeds_expectations → +1 step │
└────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Salary_grades_model │
│ compute_salary_for_step(grade_id, new_step) │
│ → new_monthly_salary │
└────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Salary_history_model │
│ save({status: 'draft', ...}) │
│ → Creates salary_history record │
└────────────────────┬─────────────────────────────────┘
│ (on approval)
▼
┌──────────────────────────────────────────────────────┐
│ sync_to_job_info($user_id) │
│ → Updates team_member_job_info table │
│ → Payroll uses new rate automatically │
└──────────────────────────────────────────────────────┘
16. Error Handling & Missing Data
Null Propagation Rules
| Scenario | Handling | Result |
|---|---|---|
| Employee not in attendance system | raw_value = null |
Metric excluded from composite |
| No tasks assigned | raw_value = null |
Task metrics excluded |
| No manual entries submitted | raw_value = null |
Manual metrics excluded |
| All metrics in category null | Category excluded | Weight redistributed |
| All metrics null | composite_score = null |
Marked as "Insufficient Data" |
Minimum Data Requirements
protected function _check_data_sufficiency($assignment_id, $metrics_total, $metrics_null)
{
// At least 60% of metrics must have data
$coverage = ($metrics_total > 0)
? (($metrics_total - $metrics_null) / $metrics_total) * 100
: 0;
if ($coverage < 60) {
return array(
'sufficient' => false,
'coverage' => round($coverage, 1),
'reason' => 'Data coverage below 60% threshold',
'metrics_null' => $metrics_null,
'metrics_total' => $metrics_total
);
}
return array('sufficient' => true, 'coverage' => round($coverage, 1));
}
Error Logging
// All calculation errors logged to CI log AND system_logs table
try {
$result = $this->$method($user_id, $start_date, $end_date);
} catch (Exception $e) {
log_message('error', "KPI Calc Error [{$metric_code}] user={$user_id}: " . $e->getMessage());
// Store error in metric_scores for visibility
$ci->Kpi_metric_scores_model->save(array(
'assignment_id' => $assignment_id,
'metric_code' => $metric_code,
'raw_value' => null,
'normalized_score'=> null,
'computation_log' => json_encode(array(
'error' => $e->getMessage(),
'calculated_at'=> get_current_utc_time(),
'status' => 'error'
))
));
}
17. Performance Optimization
Batch Query Strategy
Instead of N+1 queries per employee, batch-fetch data for all employees in a cycle:
/**
* Batch-prefetch attendance data for all employees in a cycle.
* Eliminates per-employee queries in _calc_attendance_rate().
*/
protected function _prefetch_attendance_data($user_ids, $start_date, $end_date)
{
$ci = get_instance();
$attendance_table = $ci->db->dbprefix('attendance');
$user_ids_str = implode(',', array_map('intval', $user_ids));
$sql = "SELECT user_id,
COUNT(DISTINCT DATE(in_time)) as days_present
FROM $attendance_table
WHERE user_id IN ({$user_ids_str})
AND deleted = 0
AND DATE(in_time) BETWEEN ? AND ?
AND status = 'approved'
GROUP BY user_id";
$results = $ci->db->query($sql, array($start_date, $end_date))->result();
// Index by user_id
$map = array();
foreach ($results as $row) {
$map[$row->user_id] = $row;
}
return $map;
}
Expected Performance
| Cycle Size | Without Batch | With Batch | Improvement |
|---|---|---|---|
| 50 employees × 29 metrics | ~1,500 queries | ~60 queries | 96% |
| 200 employees × 29 metrics | ~6,000 queries | ~60 queries | 99% |
| 500 employees × 29 metrics | ~15,000 queries | ~60 queries | 99.6% |
Memory Management
// Process in chunks for very large organizations
$chunk_size = 50;
$user_chunks = array_chunk($all_user_ids, $chunk_size);
foreach ($user_chunks as $chunk) {
// Prefetch data for this chunk only
$attendance_data = $this->_prefetch_attendance_data($chunk, $start, $end);
$task_data = $this->_prefetch_task_data($chunk, $start, $end);
// ... process chunk
// Free memory
unset($attendance_data, $task_data);
}
18. Calculation Audit Trail
Computation Log JSON Structure
Every metric score is stored with a computation_log JSON field for full auditability:
{
"raw_value": 95.5,
"formula": "(on_time_days / total_attendance_days) × 100",
"inputs": {
"on_time_days": 42,
"total_attendance_days": 44
},
"source": "automated",
"model": "Attendance_model",
"query_range": {
"start_date": "2026-01-01",
"end_date": "2026-03-31"
},
"calculated_at": "2026-04-01 08:15:32",
"engine_version": "1.0.0"
}
History Snapshot Structure
When a cycle is finalized, kpi_score_history captures the complete breakdown:
{
"composite_score": 3.85,
"rating_band": "exceeds_expectations",
"category_scores": {
"attendance": 4.10,
"task_performance": 3.75,
"project_delivery": 3.90,
"ticket_resolution": null,
"time_management": 3.60,
"compliance": 5.00,
"professional_development": 3.20,
"collaboration": 3.50
},
"metric_details": [
{
"code": "att_rate",
"raw_value": 97.5,
"score": 4.25,
"weight": 0.30,
"source": "automated"
}
],
"data_coverage": 86.7,
"metrics_computed": 26,
"metrics_total": 30,
"salary_recommendation": {
"step_change": 1,
"current_step": 3,
"new_step": 4,
"new_salary": 35000.00,
"status": "pending_approval"
}
}
Appendix: Utility Methods
Working Days Calculator
/**
* Count working days between two dates, excluding weekends and holidays.
*
* @param string $start_date Y-m-d
* @param string $end_date Y-m-d
* @param int $user_id For user-specific schedule
* @return int
*/
protected function _get_working_days($start_date, $end_date, $user_id = 0)
{
$ci = get_instance();
// Get user's schedule (rest days)
$rest_days = array(0, 6); // Default: Sunday, Saturday
if ($user_id) {
$ci->load->model('Schedule_model');
$schedule = $ci->Schedule_model->getUserSchedObj($user_id);
if ($schedule && isset($schedule->rest_days)) {
$rest_days = explode(',', $schedule->rest_days);
}
}
// Get holidays in range
$holidays_table = $ci->db->dbprefix('holidays');
$holidays_sql = "SELECT DATE(start_date) as holiday_date FROM $holidays_table
WHERE deleted=0 AND DATE(start_date) BETWEEN ? AND ?";
$holidays = array_column(
$ci->db->query($holidays_sql, array($start_date, $end_date))->result_array(),
'holiday_date'
);
// Count
$working_days = 0;
$current = strtotime($start_date);
$end = strtotime($end_date);
while ($current <= $end) {
$day_of_week = (int) date('w', $current);
$date_str = date('Y-m-d', $current);
if (!in_array($day_of_week, $rest_days) && !in_array($date_str, $holidays)) {
$working_days++;
}
$current = strtotime('+1 day', $current);
}
return $working_days;
}
Months-in-Range Calculator
protected function _get_months_in_range($start_date, $end_date)
{
$start = new DateTime($start_date);
$end = new DateTime($end_date);
$interval = $start->diff($end);
return max(1, ($interval->y * 12) + $interval->m + ($interval->d > 15 ? 1 : 0));
}
Auto-Trigger Check
protected function _is_salary_auto_trigger_enabled()
{
return get_setting('kpi_salary_auto_trigger', '0') === '1';
}
Next: 06 — UI & Dashboards →