KPI Matrix How-to Guide Public

05 — Calculation Engine

05 — Calculation Engine How every metric is computed, scheduled, cached, and how auto-trigger salary upgrades work. Status: Implemented as…

Guide version: r1 Module version: 1.4.0 Updated: 2026-07-22 Estimated time: 37 min 2 views

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_registry keyed by metric_key.
  • Normalization is within-band linear interpolation — bands are matched directly against the authored threshold ranges (no inversion/relabelling); for lower metrics only the within-band interpolation is reversed. Scoring now lives in Modules\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 (slug auto_calculate_kpi_cycles, nightly 30 1 * * *). It runs process_cycle() for open cycles flagged auto_calculate=1 whose period_end has passed. Manual triggering via Kpi_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

  1. Architecture Overview
  2. Metric Computation Registry
  3. Category 1: Attendance & Punctuality
  4. Category 2: Task Performance
  5. Category 3: Project Delivery
  6. Category 4: Ticket Resolution
  7. Category 5: Time Management
  8. Category 6: Compliance & Conduct
  9. Category 7: Professional Development
  10. Category 8: Collaboration & Peer Review
  11. Scoring & Normalization Pipeline
  12. Composite Score Aggregation
  13. Scheduling & Automation (Cron)
  14. Caching Strategy
  15. Auto-Trigger Salary Upgrade Engine
  16. Error Handling & Missing Data
  17. Performance Optimization
  18. 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.max5.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 lower metrics by relabelling band ranges (moving the unsatisfactory range under the outstanding key, etc.), which inverted results — 0% absence scored 1.00. That relabelling step was deleted; bands are now matched directly and only within-band interpolation is reversed for lower metrics. 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 →

Was this guide helpful?

Report a content problem