Status: Implemented (recommendation generation) — June 2026 reconciliation. Reconciliation notes:
- Recommendation generation is live in
Kpi_calculation_engine::_generate_salary_recommendation()and gated by thekpi_salary_auto_triggersetting. It now reads the employee's real grade/step/salary viaget_current_salary()and prices steps withSalary_grades_model::compute_salary_for_step()(the earlier0/0placeholder stub, doc 02 item C-5, is resolved).- Recommendations persist to
kpi_salary_recommendationskeyed by(employee_id, cycle_id)(see 03_DATABASE_SCHEMA.md §3.9).- The approval →
salary_historywrite, consecutive-rating bonuses, annual cap, effective-date resolution, and dual-approval described below are the target spec; verify each againstKpi_salary_recommendations_modelbefore relying on it. Rating→step mapping and flags are config-driven in$config['kpi_default_step_mapping'].
Table of Contents
- Overview
- Integration Architecture
- Rating-to-Step Mapping
- Salary Grade Mechanics Recap
- Progression Engine
- Approval Workflow
- Integration with Existing Models
- Multi-Period Progression Rules
- Edge Cases and Safeguards
- Retroactive and Prorated Adjustments
- Reporting and Audit Trail
- Configuration Settings
- Database Changes for Integration
- Implementation Sequence
1. Overview
The KPI Matrix module's most strategic output is its ability to automatically recommend salary step progressions based on objective performance data. This document describes how KPI composite scores feed into ERPat's existing salary grade/step system to create a transparent, data-driven compensation progression path.
Design Principles
| Principle | Description |
|---|---|
| Recommendation, Not Override | KPI scores generate salary change recommendations that require human approval |
| Grade-Aware | Progression respects salary grade boundaries (min/max/step limits) |
| Backward Compatible | Uses existing salary_history and salary_grades infrastructure |
| Auditable | Every recommendation includes the full calculation trail |
| Configurable | Mappings, caps, and rules are admin-configurable, not hardcoded |
Data Flow
┌─────────────────────────────────────────────────────────────────┐
│ KPI Composite Score │
│ (from kpi_composite_results table) │
└──────────────────┬──────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Rating Band Mapping │
│ 4.50–5.00 → Outstanding │ 3.50–4.49 → Exceeds │
│ 2.50–3.49 → Meets │ 1.50–2.49 → Needs Improve │
│ 1.00–1.49 → Unsatisfactory │ │
└──────────────────┬──────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step Recommendation Engine │
│ │
│ Rating Band │ Step Change │ Cap Check │ Grade Boundary Check │
│ Outstanding │ +2 │ ✓ │ ✓ │
│ Exceeds │ +1 │ ✓ │ ✓ │
│ Meets │ 0 │ — │ — │
│ Needs Improve │ 0 + PIP │ — │ — │
│ Unsatisfactory │ 0 + PIP │ — │ Demotion Flag │
└──────────────────┬──────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Salary Recommendation Record │
│ (kpi_salary_recommendations table) │
│ │
│ • Current grade + step + salary │
│ • Recommended grade + step + salary │
│ • Dollar/peso difference │
│ • Full computation log (JSON) │
│ • Status: pending → approved/rejected/deferred │
└──────────────────┬──────────────────────────────────────────────┘
│ (on approval)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Salary History Record │
│ (existing ci_salary_history table) │
│ │
│ • New monthly_salary, daily_rate, hourly_rate │
│ • Status: draft (requires separate approval) │
│ • Reason: "KPI Review [cycle_name] — Rating: [band]" │
│ • salary_grade_id + salary_step populated │
└─────────────────────────────────────────────────────────────────┘
2. Integration Architecture
Touchpoints with Existing ERPat Modules
| Existing Module | Integration Point | Direction |
|---|---|---|
Salary Grades (Salary_grades_model) |
Read grade structure (min/mid/max, steps, increment) | KPI → reads |
Salary History (Salary_history_model) |
Read current salary; Write new salary record on approval | KPI → reads & writes |
Payroll Helper (payroll_helper.php) |
get_current_salary(), get_salary_grade() for current state |
KPI → reads |
Job Info (team_member_job_info) |
Synced automatically via sync_to_job_info() on salary_history approval |
Indirect (via salary_history) |
System Logs (system_logs) |
Audit trail for all KPI-driven salary changes | KPI → writes |
| Roles/Permissions | salary_history_create permission required for auto-generated records |
KPI → checks |
New Components
| Component | Type | Purpose |
|---|---|---|
kpi_salary_recommendations |
DB Table | Stores generated salary recommendations with full context |
Kpi_salary_recommendations_model |
Model | CRUD + recommendation generation logic |
KpiSalaryIntegration |
Trait | Shared logic between KPI and Salary controllers |
| Settings entries | Config | Admin-configurable mapping rules |
3. Rating-to-Step Mapping
Default Mapping Table
The core mapping between KPI rating bands and salary step changes:
| Rating Band | Score Range | Step Change | Additional Action | Consecutive Bonus |
|---|---|---|---|---|
| Outstanding | 4.50 – 5.00 | +2 steps | Eligible for merit bonus flag | 3× consecutive = grade promotion eligibility |
| Exceeds Expectations | 3.50 – 4.49 | +1 step | None | 3× consecutive = +1 extra step |
| Meets Expectations | 2.50 – 3.49 | 0 steps (maintain) | None | None |
| Needs Improvement | 1.50 – 2.49 | 0 steps (maintain) | PIP triggered | 2× consecutive = salary freeze |
| Unsatisfactory | 1.00 – 1.49 | 0 steps (maintain) | Mandatory PIP | 2× consecutive = demotion review |
Configurable Mapping Structure
Stored in settings table as serialized JSON:
{
"kpi_step_mapping": {
"outstanding": {
"min_score": 4.50,
"max_score": 5.00,
"step_change": 2,
"flags": ["merit_bonus_eligible"],
"consecutive_rule": {
"count": 3,
"action": "grade_promotion_eligible"
}
},
"exceeds": {
"min_score": 3.50,
"max_score": 4.49,
"step_change": 1,
"flags": [],
"consecutive_rule": {
"count": 3,
"action": "extra_step"
}
},
"meets": {
"min_score": 2.50,
"max_score": 3.49,
"step_change": 0,
"flags": [],
"consecutive_rule": null
},
"needs_improvement": {
"min_score": 1.50,
"max_score": 2.49,
"step_change": 0,
"flags": ["pip_triggered"],
"consecutive_rule": {
"count": 2,
"action": "salary_freeze"
}
},
"unsatisfactory": {
"min_score": 1.00,
"max_score": 1.49,
"step_change": 0,
"flags": ["mandatory_pip"],
"consecutive_rule": {
"count": 2,
"action": "demotion_review"
}
}
}
}
Step Change Calculation Formula
recommended_step = current_step + step_change
// Boundary enforcement
IF recommended_step > grade.total_steps THEN
IF grade_promotion_allowed THEN
recommended_grade = next_higher_grade
recommended_step = 1 // Start at step 1 of new grade
ELSE
recommended_step = grade.total_steps // Cap at max step
flag = "at_grade_ceiling"
END IF
END IF
IF recommended_step < 1 THEN
recommended_step = 1 // Floor at step 1
END IF
// Compute new salary
new_monthly_salary = grade.min_salary + (recommended_step - 1) × grade.step_increment
// Cap check
new_monthly_salary = MIN(new_monthly_salary, grade.max_salary)
4. Salary Grade Mechanics Recap
Current ERPat Salary Grade Structure
From Salary_grades_model::compute_salary_for_step():
Grade Structure:
┌──────────────────────────────────────────────────────────┐
│ Grade N: "Senior Engineer" │
│ │
│ min_salary ─── step_increment ─── max_salary │
│ ₱50,000 ─── ₱5,000 per step ─── ₱70,000 │
│ │
│ Step 1: ₱50,000 (min_salary) │
│ Step 2: ₱55,000 (min + 1 × increment) │
│ Step 3: ₱60,000 (min + 2 × increment) │
│ Step 4: ₱65,000 (min + 3 × increment) │
│ Step 5: ₱70,000 (max_salary, capped) │
│ │
│ total_steps: 5 │
│ step_increment: (max - min) / (total_steps - 1) │
│ = (70000 - 50000) / 4 = ₱5,000 │
└──────────────────────────────────────────────────────────┘
Key Salary Helper Functions Used
| Function | Returns | Used For |
|---|---|---|
get_current_salary($user_id) |
Array with monthly_salary, salary_grade_id, salary_step, source |
Determining current position |
get_salary_grade($user_id) |
Array with grade_id, grade_name, step, total_steps, min_salary, max_salary, step_increment |
Grade boundary checks |
get_latest_salary_record($user_id) |
Latest approved salary_history row |
Checking effective dates |
Grade Progression Path
Salary Grades (ordered by grade_level):
Grade 1: Entry Level ₱15,000 – ₱20,000 (5 steps)
Grade 2: Associate ₱20,000 – ₱30,000 (5 steps)
Grade 3: Senior ₱30,000 – ₱45,000 (5 steps)
Grade 4: Lead ₱45,000 – ₱65,000 (4 steps)
Grade 5: Manager ₱65,000 – ₱90,000 (4 steps)
Grade 6: Senior Manager ₱90,000 – ₱120,000 (3 steps)
Grade 7: Director ₱120,000 – ₱160,000 (3 steps)
Grade 8: Executive ₱160,000 – ₱220,000 (3 steps)
Within-grade progression: Step changes (horizontal movement)
Cross-grade progression: Grade promotion (vertical movement)
5. Progression Engine
Algorithm: generate_salary_recommendation()
FUNCTION generate_salary_recommendation(user_id, cycle_id):
// Step 1: Get KPI composite result
composite = kpi_composite_results WHERE user_id AND cycle_id
IF composite IS NULL OR composite.status != 'finalized' THEN
RETURN error("No finalized KPI result for this user/cycle")
END IF
// Step 2: Get current salary state
current = get_current_salary(user_id)
grade_info = get_salary_grade(user_id)
IF current.source == 'job_info' AND current.salary_grade_id IS NULL THEN
// Employee not on graded pay structure
RETURN warning("Employee not assigned to salary grade — manual review required")
END IF
// Step 3: Determine rating band
band = resolve_rating_band(composite.composite_score)
mapping = get_step_mapping(band)
// Step 4: Check consecutive history
consecutive = count_consecutive_ratings(user_id, band)
bonus_action = NULL
IF mapping.consecutive_rule AND consecutive >= mapping.consecutive_rule.count THEN
bonus_action = mapping.consecutive_rule.action
END IF
// Step 5: Calculate new step
new_step = current.salary_step + mapping.step_change
// Step 5a: Apply consecutive bonus
IF bonus_action == 'extra_step' THEN
new_step = new_step + 1
END IF
// Step 6: Boundary checks
new_grade_id = current.salary_grade_id
IF new_step > grade_info.total_steps THEN
IF bonus_action == 'grade_promotion_eligible' OR auto_promote_enabled() THEN
next_grade = get_next_grade(current.salary_grade_id)
IF next_grade IS NOT NULL THEN
new_grade_id = next_grade.id
new_step = 1 // Start at step 1 of new grade
grade_info = next_grade // Use new grade for salary calc
ELSE
new_step = grade_info.total_steps // At highest grade, cap step
SET flag "at_highest_grade_ceiling"
END IF
ELSE
new_step = grade_info.total_steps // Cap at max step
SET flag "at_grade_ceiling"
END IF
END IF
IF new_step < 1 THEN
new_step = 1
END IF
// Step 7: Compute new salary
new_monthly = grade_info.min_salary + (new_step - 1) * grade_info.step_increment
new_monthly = MIN(new_monthly, grade_info.max_salary)
salary_change = new_monthly - current.monthly_salary
change_percentage = (salary_change / current.monthly_salary) * 100
// Step 8: Build recommendation record
recommendation = {
user_id: user_id,
cycle_id: cycle_id,
composite_result_id: composite.id,
composite_score: composite.composite_score,
rating_band: band,
current_grade_id: current.salary_grade_id,
current_step: current.salary_step,
current_monthly_salary: current.monthly_salary,
recommended_grade_id: new_grade_id,
recommended_step: new_step,
recommended_monthly_salary: new_monthly,
salary_change: salary_change,
change_percentage: change_percentage,
step_change: mapping.step_change,
consecutive_count: consecutive,
bonus_action: bonus_action,
flags: mapping.flags,
computation_log: {
algorithm_version: "1.0",
timestamp: UTC_NOW,
inputs: { composite_score, current_step, grade_info },
mapping_used: mapping,
boundary_checks: { ... },
result: { new_step, new_grade_id, new_monthly }
},
status: 'pending',
effective_date: cycle.review_end_date + 1 month (start of next month)
}
RETURN recommendation
END FUNCTION
Batch Generation
When a review cycle is finalized, recommendations are generated for ALL employees in that cycle:
FUNCTION generate_batch_recommendations(cycle_id):
assignments = kpi_employee_assignments WHERE cycle_id AND status = 'active'
results = { generated: 0, skipped: 0, errors: 0, warnings: 0 }
FOR EACH assignment IN assignments:
TRY:
recommendation = generate_salary_recommendation(
assignment.user_id, cycle_id
)
IF recommendation.type == 'warning' THEN
results.warnings++
log_warning(recommendation)
ELSE
save_recommendation(recommendation)
results.generated++
END IF
CATCH error:
results.errors++
log_error(assignment.user_id, error)
END TRY
END FOR
RETURN results
END FUNCTION
6. Approval Workflow
Recommendation Status Flow
┌──────────┐
│ Pending │ ← auto-generated
└────┬─────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌──────────┐
│ Approved │ │Rejected│ │ Deferred │
└────┬─────┘ └────────┘ └────┬─────┘
│ │
▼ │ (next cycle)
┌────────────────┐ │
│ Salary History │ ▼
│ Record │ Re-evaluated
│ (status=draft) │
└────────────────┘
Approval Levels
| Level | Who | What They Do |
|---|---|---|
| Level 1: HR Manager | Users with salary_history_create permission |
Reviews recommendation, approves/rejects/defers |
| Level 2: Department Head (optional) | Department manager | Co-signs if salary change > configurable threshold |
| Level 3: Admin (optional) | System admin | Final approval for grade promotions |
Approval Actions
Approve:
- Recommendation status →
approved - New
salary_historyrecord created automatically:user_id= recommendation.user_idmonthly_salary= recommendation.recommended_monthly_salarydaily_rate= computed from monthly ÷ (days_per_year / 12)hourly_rate= daily_rate ÷ hours_per_daysalary_grade_id= recommendation.recommended_grade_idsalary_step= recommendation.recommended_stepstatus= 'draft' (follows existing salary_history approval flow)reason= "KPI Review: [cycle_name] — Rating: [band] ([score])"effective_date= recommendation.effective_date
- System log entry created
- Notification sent to employee (optional)
Reject:
- Recommendation status →
rejected rejected_by,rejected_at,rejection_reasonpopulated- System log entry created
- No salary_history record generated
Defer:
- Recommendation status →
deferred deferred_reason,deferred_untilpopulated- Will be re-evaluated in next cycle (or manually before then)
- System log entry created
Override Capability
HR can override the recommended step change before approval:
// In Kpi_salary_recommendations controller
function approve_with_override() {
$id = $this->input->post('id');
$override_step = (int) $this->input->post('override_step');
$override_reason = $this->input->post('override_reason');
// Recalculate salary based on override step
$recommendation = $this->Kpi_salary_recommendations_model->get_one($id);
$grade = $this->Salary_grades_model->get_one($recommendation->recommended_grade_id);
$new_monthly = $grade->min_salary + ($override_step - 1) * $grade->step_increment;
$new_monthly = min($new_monthly, $grade->max_salary);
// Save override
$update_data = array(
'recommended_step' => $override_step,
'recommended_monthly_salary' => $new_monthly,
'override_reason' => $override_reason,
'overridden_by' => $this->login_user->id,
'status' => 'approved'
);
$this->Kpi_salary_recommendations_model->save($update_data, $id);
// Create salary history record with overridden values
$this->_create_salary_history_from_recommendation($id);
}
7. Integration with Existing Models
Reading Current State
// In KPI Salary Integration trait/library
/**
* Get employee's current salary position for KPI recommendation
*/
function get_employee_salary_position($user_id) {
// Uses existing payroll helper functions
$salary_info = get_current_salary($user_id);
$grade_info = get_salary_grade($user_id);
return array(
'has_grade' => !empty($grade_info),
'grade_id' => $grade_info ? $grade_info['grade_id'] : null,
'grade_name' => $grade_info ? $grade_info['grade_name'] : null,
'grade_level' => $grade_info ? $grade_info['grade_level'] : null,
'current_step' => $grade_info ? $grade_info['step'] : null,
'total_steps' => $grade_info ? $grade_info['total_steps'] : null,
'step_increment' => $grade_info ? $grade_info['step_increment'] : null,
'min_salary' => $grade_info ? $grade_info['min_salary'] : null,
'max_salary' => $grade_info ? $grade_info['max_salary'] : null,
'current_monthly' => $salary_info['monthly_salary'],
'current_daily' => $salary_info['daily_rate'],
'current_hourly' => $salary_info['hourly_rate'],
'hours_per_day' => $salary_info['hours_per_day'],
'days_per_year' => $salary_info['days_per_year'],
'salary_source' => $salary_info['source'],
'is_at_ceiling' => ($grade_info && $grade_info['step'] >= $grade_info['total_steps']),
);
}
Writing Salary History Record
/**
* Create salary_history record from approved KPI recommendation
* Follows existing Salary_history::save() pattern
*/
function create_salary_record_from_kpi($recommendation) {
$position = $this->get_employee_salary_position($recommendation->user_id);
// Compute derived rates (matching existing salary_history pattern)
$monthly = $recommendation->recommended_monthly_salary;
$days_per_year = $position['days_per_year'] ?: 261;
$hours_per_day = $position['hours_per_day'] ?: 8;
$days_per_month = $days_per_year / 12;
$daily_rate = $monthly / $days_per_month;
$hourly_rate = $daily_rate / $hours_per_day;
$salary_data = array(
'user_id' => $recommendation->user_id,
'effective_date' => $recommendation->effective_date,
'payroll_basis' => 'salary_based',
'salary_term' => 'regular',
'salary_grade_id' => $recommendation->recommended_grade_id,
'salary_step' => $recommendation->recommended_step,
'monthly_salary' => round($monthly, 2),
'daily_rate' => round($daily_rate, 2),
'hourly_rate' => round($hourly_rate, 2),
'hours_per_day' => $hours_per_day,
'days_per_year' => $days_per_year,
'reason' => sprintf(
"KPI Review: %s — Rating: %s (%.2f) — Step %d→%d",
$recommendation->cycle_name,
ucfirst($recommendation->rating_band),
$recommendation->composite_score,
$recommendation->current_step,
$recommendation->recommended_step
),
'status' => 'draft', // Follows existing approval workflow
'created_by' => $this->login_user->id,
'created_at' => get_current_utc_time()
);
$save_id = $this->Salary_history_model->save($salary_data);
if ($save_id) {
// Link recommendation to salary_history record
$this->Kpi_salary_recommendations_model->save(array(
'salary_history_id' => $save_id
), $recommendation->id);
// System log
set_system_logs(
lang('kpi_salary_recommendation_approved') . " for " . get_user_legal_name($recommendation->user_id, false),
obj_to_arr_serialization((object) $salary_data, array("deleted"), "kpi_salary"),
"create", "kpi_salary",
array(
"user_id" => $this->login_user->id,
"remarks" => "Auto-generated from KPI cycle " . $recommendation->cycle_id
),
$save_id
);
}
return $save_id;
}
Getting Next Grade
/**
* Find the next higher salary grade for promotion scenarios
*/
function get_next_grade($current_grade_id) {
$current = $this->Salary_grades_model->get_one($current_grade_id);
if (!$current || !$current->grade_level) {
return null;
}
$grades_table = $this->db->dbprefix('salary_grades');
$sql = "SELECT * FROM $grades_table
WHERE grade_level > ?
AND status = 'active'
AND deleted = 0
ORDER BY grade_level ASC
LIMIT 1";
return $this->db->query($sql, array($current->grade_level))->row();
}
8. Multi-Period Progression Rules
Consecutive Rating Tracking
The system tracks consecutive identical (or same-band) ratings to apply bonus/penalty rules:
/**
* Count consecutive identical rating bands for a user
* Looks backward from the most recent finalized cycle
*/
function count_consecutive_ratings($user_id, $current_band) {
$table = $this->db->dbprefix('kpi_composite_results');
$cycles_table = $this->db->dbprefix('kpi_review_cycles');
$sql = "SELECT cr.rating_band, c.period_end
FROM $table cr
JOIN $cycles_table c ON c.id = cr.cycle_id
WHERE cr.user_id = ?
AND cr.status = 'finalized'
AND cr.deleted = 0
ORDER BY c.period_end DESC
LIMIT 10"; // Look back maximum 10 cycles
$results = $this->db->query($sql, array($user_id))->result();
$count = 0;
foreach ($results as $result) {
if ($result->rating_band === $current_band) {
$count++;
} else {
break; // Streak broken
}
}
return $count;
}
Progressive Rules Matrix
| Scenario | Consecutive Count | Action | Example |
|---|---|---|---|
| Outstanding × 3 | 3 cycles of 4.50+ | Grade promotion eligible | Grade 3 Step 5 → Grade 4 Step 1 |
| Exceeds × 3 | 3 cycles of 3.50–4.49 | Extra +1 step | +1 normal + 1 bonus = +2 total |
| Needs Improvement × 2 | 2 cycles of 1.50–2.49 | Salary freeze flag | No step change even if rating improves |
| Unsatisfactory × 2 | 2 cycles of 1.00–1.49 | Demotion review flag | HR must review; possible grade demotion |
Annual Cap
To prevent excessive salary growth in a single year (e.g., quarterly reviews giving 4× step increases):
annual_step_cap = get_setting('kpi_annual_step_cap', 3)
// Calculate steps already given this calendar year
ytd_steps = SUM of step_changes from approved recommendations
WHERE user_id AND YEAR(effective_date) = CURRENT_YEAR
remaining_allowance = annual_step_cap - ytd_steps
// Apply cap
actual_step_change = MIN(recommended_step_change, remaining_allowance)
IF actual_step_change < recommended_step_change THEN
SET flag "annual_cap_applied"
computation_log.cap_details = {
recommended: recommended_step_change,
allowed: actual_step_change,
ytd_total: ytd_steps,
annual_cap: annual_step_cap
}
END IF
9. Edge Cases and Safeguards
Edge Case 1: Employee Not on Graded Pay
Scenario: Employee has salary in job_info or salary_history but no salary_grade_id.
Handling:
- Generate recommendation with
status = 'manual_review' - Flag:
"no_salary_grade" - Display warning in HR dashboard
- HR must manually assign grade/step or skip salary recommendation
Edge Case 2: At Grade Ceiling (Max Step)
Scenario: Employee is at Step 5 of 5 in their grade and earns "Exceeds" rating (+1 step).
Handling:
- Check
kpi_auto_grade_promotionsetting - If enabled → attempt grade promotion to next grade, Step 1
- If disabled → cap at current max step, set flag
"at_grade_ceiling" - If already at highest grade → cap at max step, set flag
"at_highest_grade_ceiling"
Edge Case 3: Grade Promotion Salary Decrease
Scenario: Step 5 of Grade 3 = ₱45,000 but Step 1 of Grade 4 = ₱45,000. No actual raise.
Handling:
- Compare new salary vs current salary
- If new salary ≤ current salary, set flag
"promotion_no_raise" - HR can review and optionally start at Step 2+ of new grade
- UI highlights this condition for reviewer attention
Edge Case 4: Salary Grade Structure Changed Mid-Cycle
Scenario: Admin modifies salary grade min/max/steps between cycle start and recommendation generation.
Handling:
- Always use current (latest) grade structure at time of recommendation
- Include
grade_snapshotin computation log:{ "grade_snapshot": { "grade_id": 3, "min_salary": 30000, "max_salary": 45000, "total_steps": 5, "step_increment": 3750, "captured_at": "2026-03-15 10:30:00" } } - If grade was modified after recommendation but before approval, show warning
Edge Case 5: Employee Transferred Between Grades
Scenario: Employee was promoted to a new grade between cycle start and end.
Handling:
- Use the salary position at cycle end date
- Set flag
"grade_changed_during_cycle" - Include both old and new grade in computation log
Edge Case 6: No Active Salary Grades in System
Scenario: Organization hasn't configured any salary grades.
Handling:
- KPI scoring works independently (composite scores always calculated)
- Salary recommendation generation is skipped
- Admin dashboard shows setup prompt: "Configure Salary Grades to enable compensation progression"
Edge Case 7: Negative Step Change (Demotion)
Scenario: Policy allows explicit demotion for sustained poor performance.
Handling:
- Default: Step changes are never negative (maintain at minimum)
- Optional setting
kpi_allow_step_demotion(default: false) - If enabled, demotion requires double approval (HR + Admin)
- System log severity:
critical
Edge Case 8: Part-Time or Probationary Employees
Scenario: Employees on probation or part-time may have different progression rules.
Handling:
- Check
salary_termfrom salary_history (regular, probationary, contractual) - Probationary employees: recommendations flagged with
"probationary_employee" - Configurable:
kpi_exclude_probationarysetting (default: false) - Part-time: Salary computations respect prorated rates
10. Retroactive and Prorated Adjustments
Effective Date Logic
Recommendations use the following effective date calculation:
DEFAULT: First day of month following cycle end date
EXAMPLE:
Cycle period: Jan 1, 2026 – Mar 31, 2026
Cycle finalized: Apr 15, 2026
Recommendation effective_date: May 1, 2026
CONFIGURABLE:
kpi_effective_date_rule = "next_month_start" (default)
| "cycle_end_plus_days:30"
| "manual" (HR sets date during approval)
Delayed Approval Scenario
If a recommendation is approved after the effective date has passed:
Recommendation effective_date: May 1, 2026
Approval date: Jun 10, 2026
OPTION A (Default): Use original effective_date (May 1)
→ Salary history record effective May 1
→ Payroll may need retroactive adjustment
OPTION B (Configurable): Use approval date
→ Salary history record effective Jun 10
→ No retroactive adjustment needed
Setting: kpi_delayed_approval_policy = "original_date" | "approval_date"
Prorating for Mid-Period Changes
Not directly managed by KPI module — the payroll system handles prorating via existing Salary_history_model::get_rate_for_date() which returns the applicable rate for any given date.
11. Reporting and Audit Trail
System Logs Configuration
Add to application/config/system_logs.php:
// KPI Salary Integration events
'create:kpi_salary' => array(
'category' => 'Human Resource',
'module' => 'KPI Salary Integration',
'component' => 'kpi_salary',
'action' => 'create',
'severity' => 'warning',
'description' => 'KPI salary recommendation was created'
),
'update:kpi_salary' => array(
'category' => 'Human Resource',
'module' => 'KPI Salary Integration',
'component' => 'kpi_salary',
'action' => 'update',
'severity' => 'warning',
'description' => 'KPI salary recommendation was updated'
),
'approve:kpi_salary' => array(
'category' => 'Human Resource',
'module' => 'KPI Salary Integration',
'component' => 'kpi_salary',
'action' => 'approve',
'severity' => 'critical',
'description' => 'KPI salary recommendation was approved (salary record created)'
),
'reject:kpi_salary' => array(
'category' => 'Human Resource',
'module' => 'KPI Salary Integration',
'component' => 'kpi_salary',
'action' => 'reject',
'severity' => 'warning',
'description' => 'KPI salary recommendation was rejected'
),
Salary Progression Report
A new report showing KPI-driven salary changes over time:
| Column | Source |
|---|---|
| Employee | users table |
| Department | team via FIND_IN_SET |
| Cycle | kpi_review_cycles.cycle_name |
| Score | kpi_composite_results.composite_score |
| Rating | kpi_salary_recommendations.rating_band |
| Previous Salary | kpi_salary_recommendations.current_monthly_salary |
| New Salary | kpi_salary_recommendations.recommended_monthly_salary |
| Change (%) | Computed |
| Status | kpi_salary_recommendations.status |
| Approved By | users table (approver) |
| Effective Date | kpi_salary_recommendations.effective_date |
Compensation Impact Summary
Aggregate report for budget planning:
SELECT
c.cycle_name,
COUNT(r.id) as total_recommendations,
SUM(CASE WHEN r.status = 'approved' THEN 1 ELSE 0 END) as approved_count,
SUM(CASE WHEN r.status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
SUM(CASE WHEN r.status = 'pending' THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN r.status = 'approved' THEN r.salary_change ELSE 0 END) as total_monthly_impact,
SUM(CASE WHEN r.status = 'approved' THEN r.salary_change ELSE 0 END) * 12 as total_annual_impact,
AVG(CASE WHEN r.status = 'approved' THEN r.change_percentage ELSE NULL END) as avg_increase_pct,
AVG(r.composite_score) as avg_kpi_score
FROM kpi_salary_recommendations r
JOIN kpi_review_cycles c ON c.id = r.cycle_id
WHERE r.deleted = 0
GROUP BY r.cycle_id
ORDER BY c.period_end DESC;
12. Configuration Settings
New Settings (stored in settings table)
| Setting Key | Default | Type | Description |
|---|---|---|---|
kpi_salary_integration_enabled |
0 |
Boolean | Master toggle for salary integration |
kpi_step_mapping |
(see Section 3) | JSON | Rating band → step change mapping |
kpi_auto_grade_promotion |
0 |
Boolean | Allow automatic grade promotion at ceiling |
kpi_annual_step_cap |
3 |
Integer | Maximum step increases per calendar year |
kpi_allow_step_demotion |
0 |
Boolean | Allow negative step changes |
kpi_effective_date_rule |
next_month_start |
String | How to calculate effective date |
kpi_delayed_approval_policy |
original_date |
String | Behavior when approved after effective date |
kpi_exclude_probationary |
0 |
Boolean | Skip probationary employees |
kpi_require_dual_approval |
0 |
Boolean | Require two-level approval |
kpi_dual_approval_threshold |
10 |
Decimal | % increase threshold for dual approval |
kpi_auto_generate_on_finalize |
1 |
Boolean | Auto-generate recommendations when cycle finalized |
kpi_send_recommendation_notification |
1 |
Boolean | Notify employees of recommendations |
kpi_salary_recommendation_visibility |
hr_only |
String | hr_only, manager_and_hr, employee_visible |
Settings UI Location
Added as a subsection in Settings > Payroll > KPI Salary Integration:
Settings > Payroll
├── General Payroll Config
├── Payslip Templates
├── KPI Salary Integration ← NEW
│ ├── Enable/Disable toggle
│ ├── Step Mapping Editor (JSON visual editor)
│ ├── Annual Cap setting
│ ├── Auto-promotion toggle
│ ├── Approval settings
│ └── Notification settings
└── Other payroll settings
13. Database Changes for Integration
New Table: kpi_salary_recommendations
CREATE TABLE IF NOT EXISTS `ci_kpi_salary_recommendations` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
-- KPI Context
`user_id` INT(11) NOT NULL,
`cycle_id` INT(11) NOT NULL,
`composite_result_id` INT(11) NOT NULL,
`composite_score` DECIMAL(4,2) NOT NULL,
`rating_band` VARCHAR(30) NOT NULL COMMENT 'outstanding, exceeds, meets, needs_improvement, unsatisfactory',
-- Current State (snapshot at recommendation time)
`current_grade_id` INT(11) NULL,
`current_step` INT(11) NULL,
`current_monthly_salary` DECIMAL(18,2) NOT NULL,
-- Recommendation
`recommended_grade_id` INT(11) NULL,
`recommended_step` INT(11) NULL,
`recommended_monthly_salary` DECIMAL(18,2) NOT NULL,
`salary_change` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`change_percentage` DECIMAL(6,2) NOT NULL DEFAULT 0.00,
-- Calculation Context
`step_change` INT(11) NOT NULL DEFAULT 0,
`consecutive_count` INT(11) NOT NULL DEFAULT 0,
`bonus_action` VARCHAR(50) NULL,
`flags` TEXT NULL COMMENT 'JSON array of flags',
`computation_log` LONGTEXT NULL COMMENT 'Full calculation audit trail (JSON)',
-- Override (if HR modifies recommendation before approval)
`override_step` INT(11) NULL,
`override_salary` DECIMAL(18,2) NULL,
`override_reason` TEXT NULL,
`overridden_by` INT(11) NULL,
-- Workflow
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, approved, rejected, deferred, manual_review',
`effective_date` DATE NULL,
`approved_by` INT(11) NULL,
`approved_at` DATETIME NULL,
`rejected_by` INT(11) NULL,
`rejected_at` DATETIME NULL,
`rejection_reason` TEXT NULL,
`deferred_reason` TEXT NULL,
`deferred_until` DATE NULL,
-- Link to created salary record
`salary_history_id` INT(11) NULL COMMENT 'FK to salary_history (populated on approval)',
-- Trailing columns (ERPat standard)
`created_by` INT(11) NULL,
`date_created` DATETIME NULL,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_ksr_user` (`user_id`),
INDEX `idx_ksr_cycle` (`cycle_id`),
INDEX `idx_ksr_status` (`status`),
INDEX `idx_ksr_deleted` (`deleted`),
UNIQUE INDEX `idx_ksr_user_cycle` (`user_id`, `cycle_id`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Column Additions to Existing Tables
-- Add to kpi_composite_results (link to recommendation)
ALTER TABLE `ci_kpi_composite_results`
ADD `salary_recommendation_id` INT(11) NULL AFTER `salary_step_recommendation`;
-- Add to kpi_review_cycles (track batch generation)
ALTER TABLE `ci_kpi_review_cycles`
ADD `recommendations_generated` TINYINT(1) NOT NULL DEFAULT 0 AFTER `status`,
ADD `recommendations_generated_at` DATETIME NULL AFTER `recommendations_generated`;
14. Implementation Sequence
Phase Dependencies
Prerequisite: Salary Grades configured (existing feature)
Prerequisite: KPI Review Cycles finalized (from 05_CALCULATION_ENGINE)
Phase 1: Database & Model
→ Create kpi_salary_recommendations table (migration)
→ Create Kpi_salary_recommendations_model
→ Add settings entries
Phase 2: Recommendation Engine
→ Implement generate_salary_recommendation()
→ Implement batch generation
→ Implement consecutive rating logic
Phase 3: Approval Workflow
→ Create recommendation review UI
→ Implement approve/reject/defer actions
→ Implement salary_history integration
→ Add system log events
Phase 4: Reporting
→ Salary progression report
→ Compensation impact summary
→ Budget projection tools
Phase 5: Configuration UI
→ Settings page for mapping editor
→ Visual step mapping builder
→ Threshold configuration
Integration Testing Checklist
- [ ] Employee with grade/step gets correct recommendation
- [ ] Employee without grade shows "manual review" flag
- [ ] At-ceiling employee triggers grade promotion logic
- [ ] At-highest-grade ceiling caps correctly
- [ ] Consecutive rating bonus applies after 3 cycles
- [ ] Annual step cap prevents excessive increases
- [ ] Approval creates salary_history record with correct values
- [ ] Rejection does not create salary_history record
- [ ] Override allows HR to modify before approval
- [ ] System logs capture all actions
- [ ] Deferred recommendations carry to next cycle
- [ ] Probationary exclusion works when enabled
- [ ] Daily/hourly rates derive correctly from monthly
- [ ] Effective date calculation follows configured rule
Appendix: Worked Example
Scenario
Employee: Maria Santos, Software Developer
Current Position: Grade 3 (Senior), Step 3 of 5
Current Salary: ₱37,500/month
Grade 3 Range: ₱30,000 – ₱45,000 (step increment: ₱3,750)
Q1 2026 KPI Result:
- Composite Score: 4.62 (Outstanding)
- Previous Q4 2025: 4.55 (Outstanding)
- Previous Q3 2025: 4.71 (Outstanding)
Calculation
1. Rating Band: Outstanding (4.62 is in 4.50-5.00 range)
2. Step Change: +2 (Outstanding mapping)
3. Consecutive Check: 3 consecutive Outstanding ratings!
→ Consecutive rule: grade_promotion_eligible
4. New Step: 3 + 2 = 5 (within Grade 3's 5 steps)
5. But: consecutive bonus triggers grade promotion check
Since at Step 5 of Grade 3 after +2 increase:
→ Check auto_grade_promotion setting: enabled
→ Next grade: Grade 4 (Lead), ₱45,000 – ₱65,000, 4 steps
→ Grade promotion available!
Decision: Promote to Grade 4, Step 1
→ New salary: ₱45,000 (Grade 4, Step 1)
→ Increase: ₱45,000 - ₱37,500 = ₱7,500/month (+20%)
Alternative without promotion:
→ Stay at Grade 3, Step 5: ₱45,000 (cap at max)
→ Increase: ₱45,000 - ₱37,500 = ₱7,500/month (+20%)
→ Flag: at_grade_ceiling (same salary but no promotion path)
Recommendation record:
current_grade: 3, current_step: 3, current_salary: 37,500
recommended_grade: 4, recommended_step: 1, recommended_salary: 45,000
salary_change: +7,500, change_percentage: +20.00%
flags: ["merit_bonus_eligible", "grade_promotion"]
bonus_action: "grade_promotion_eligible"
consecutive_count: 3
What HR Sees
┌─────────────────────────────────────────────────────────┐
│ SALARY RECOMMENDATION │
│ │
│ Employee: Maria Santos (Software Developer) │
│ KPI Cycle: Q1 2026 — Score: 4.62 ★★★★★ Outstanding │
│ Consecutive Outstanding: 3 cycles │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ CURRENT │ → │ RECOMMENDED │ │
│ │ Grade 3 / S3 │ │ Grade 4 / S1 │ ▲ +₱7,500 │
│ │ ₱37,500/mo │ │ ₱45,000/mo │ (+20.00%) │
│ └─────────────┘ └─────────────┘ │
│ │
│ ???? Grade Promotion (3× consecutive Outstanding) │
│ ⭐ Merit Bonus Eligible │
│ │
│ Effective Date: May 1, 2026 │
│ │
│ [Approve] [Approve with Override] [Reject] [Defer] │
└─────────────────────────────────────────────────────────┘
Document Version: 1.0
Last Updated: Feb 2026
Module: KPI Matrix — Salary Integration
Depends on: 02_KPI_CATEGORIES_AND_METRICS.md, 03_DATABASE_SCHEMA.md
Next: 05_CALCULATION_ENGINE.md