Document: KPI Categories, Individual Metrics, Scoring Scales, Weights & Aggregation Module: KPI Matrix (Employee Performance Management) Target System: ERPat ERP (CodeIgniter 3 / PHP 8.2+) Status: Implemented — documentation reconciled to code Last Revised: June 2026 (aligned to
modules/KpiMatrix/config/kpi_templates.php+modules/KpiMatrix/libraries/Kpi_calculation_engine.php)
Table of Contents
- Overview & KPI Design Philosophy
- How to Read a Metric Definition
- Scoring Scale Standard
- Normalization (As Implemented)
- Category 1 — Attendance & Punctuality (ATT)
- Category 2 — Task Performance (TSK)
- Category 3 — Project Delivery (PRJ)
- Category 4 — Support & Service Quality (TKT)
- Category 5 — Behavioral Compliance (BHV)
- Category 6 — Leave Management (LVE)
- Category 7 — Professional Development (PRF)
- Category 8 — Time Management (TMS)
- Weight Distribution Templates
- Composite Score Calculation
- Rating Bands & Salary Mapping
- Data Sufficiency & Edge Cases
- Appendix A — Master Metric Register (29 Metrics)
- Appendix B — Best-Practice Enhancements (Future)
- Appendix C — Implementation Fidelity Notes
1. Overview & KPI Design Philosophy
The KPI Matrix organizes employee performance into 8 categories containing 29 metrics in total. Every metric is computed automatically from data ERPat already captures, normalized to a common 1.00–5.00 scale, weighted, and rolled up into a single composite score that drives review ratings and salary-progression recommendations.
This document is the single source of truth for metric semantics. The runtime definitions live in modules/KpiMatrix/config/kpi_templates.php (categories, metric metadata, thresholds, templates) and the formulas live in modules/KpiMatrix/libraries/Kpi_calculation_engine.php. When this document and code disagree, code wins and this document must be updated — see Appendix C for the known reconciliation list.
1.1 Design Principles
These principles are derived from current performance-management best practice (balanced scorecard, SMART KPIs, and the leading/lagging-indicator framework) and adapted to data ERPat actually holds:
- Data-driven first. 25 of 29 metrics compute with zero manual grading. The remaining 4 are manual/semi-manual because the underlying signal (subjective quality, customer satisfaction, off-system training) does not yet exist as structured data — see Appendix B.
- Balanced dimensions. Each category mixes the four performance dimensions so no single behavior dominates:
- Quantity — how much was produced (task throughput, tickets resolved).
- Quality — how good the output was (task quality, ticket satisfaction).
- Efficiency — output relative to time/resource (resolution time, productive-hours ratio).
- Reliability / Compliance — consistency and rule-adherence (attendance, schedule adherence, discipline).
- Leading + lagging balance. Lagging indicators confirm results that already happened (attendance rate, task completion); leading indicators predict future performance (certifications, training, leave/overtime health as burnout signals). Both are tagged per metric so reviewers can act on leading signals before lagging ones deteriorate.
- Bi-directional & normalized. Some metrics reward higher values (
direction: higher); some penalize them (direction: lower). All are normalized onto the same 1.00–5.00 band so they can be compared and averaged fairly across units. - Configurable, not hard-coded. Category weights, metric weights, and scoring thresholds are stored per template and per template-metric (
override_thresholds), so the same engine serves a developer, a call-center agent, and a production worker with different emphases. - Auditable. Every computed score persists its
raw_value, the normalized score, and a JSONcomputation_log(inputs + formula + timestamp) inkpi_metric_scores, so any rating is fully reconstructable.
1.2 Avoiding Common KPI Pitfalls
| Pitfall | How this design avoids it |
|---|---|
| Vanity metrics (look good, mean little) | Every metric maps to a documented business behavior and a performance dimension; pure-volume metrics (throughput, tickets) are balanced by quality/reliability metrics in the same category. |
| Gaming a single number | Composite is a weighted blend of 8 categories; over-optimizing one (e.g. overtime) is neutralized by bell-curve scoring and counter-metrics (undertime, leave health). |
| One-size-fits-all | Five role-based weight templates; categories can be set to 0% to disable for a role. |
| Measuring activity, not outcome | On-time delivery, SLA-style resolution time, and project completion measure outcomes, not just effort. |
| Too many metrics | Best practice favors 3–8 active drivers per scope. Each category carries 3–5 metrics; templates can disable the long tail to keep an employee's scorecard focused. |
2. How to Read a Metric Definition
Each metric below is documented with a consistent header block:
| Field | Meaning |
|---|---|
| Key | The metric_key used in kpi_templates.php, kpi_template_metrics, and kpi_metric_scores. Stable identifier — never rename without a migration. |
| Dimension | Quantity / Quality / Efficiency / Reliability / Compliance / Development / Wellbeing. |
| Indicator | Lagging (confirms past result) or Leading (predicts future result). |
| Direction | higher (↑ better), lower (↓ better), or bell (a target band is best — encoded via non-monotonic thresholds). |
| Automation | Automated / Semi-automated (auto with manual fallback) / Manual. |
| Source | The model/method or SQL table(s) the engine reads. |
| Formula | The exact raw_value formula the engine computes (mirrors the computation_log.formula string). |
| Thresholds | The five raw-value bands from kpi_templates.php that map to the 1–5 scale. |
| SMART target | A recommended, configurable target expressed in SMART terms for reviewers setting expectations. |
3. Scoring Scale Standard
All metrics normalize to a 1.00–5.00 continuous scale, stored as DECIMAL(4,2):
| Score Range | Rating Band (rating_band) |
Meaning | Color |
|---|---|---|---|
| 4.50 – 5.00 | outstanding |
Consistently exceeds expectations | #27ae60 |
| 3.50 – 4.49 | exceeds_expectations |
Frequently surpasses requirements | #2ecc71 |
| 2.50 – 3.49 | meets_expectations |
Satisfactorily fulfills the role | #f39c12 |
| 1.50 – 2.49 | needs_improvement |
Below acceptable standard | #e67e22 |
| 1.00 – 1.49 | unsatisfactory |
Significantly below minimum | #e74c3c |
These bands are defined once in
$config['kpi_rating_bands']and consumed by both the engine (_get_rating_band()) and the UI badges. The 1–5 scale (not 0–100) is the canonical internal scale; raw values are only ever shown alongside the normalized score for transparency.
4. Normalization (As Implemented)
The engine method _normalize_score($raw_value, $thresholds, $direction) — which delegates to the pure Modules\KpiMatrix\Services\ScoreNormalizer (library) — converts a raw value to a 1.00–5.00 score using within-band linear interpolation, not a single global linear map. This is the authoritative algorithm:
-
Direction handling. Bands are matched directly against the authored threshold ranges — they are never relabelled or swapped. For
direction = 'lower'metrics, only the within-band interpolation is reversed (step 3): a low raw value earns the high end of its band's score window. (The config authors every band quality-first, so alowermetric's best/lowest raw values already sit in theoutstandingband.) -
Band lookup. The raw value is matched against the five threshold bands. Each band maps to a score sub-range:
Band Score sub-range 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 -
Interpolation within the band. Position inside the band's raw range determines the exact score. For
highermetrics position runs from the band's min upward; forlowermetrics it is reversed (the only placedirectionchanges the result):position = (raw_value − band_min) / (band_max − band_min) // direction = 'higher' position = (band_max − raw_value) / (band_max − band_min) // direction = 'lower' score = band_base + position × (band_ceiling − band_base) score = CLAMP(score, 1.00, 5.00) -
Out-of-range → clamp to global range. Before matching, the raw value is clamped into the authored global
[min, max]range (the union of all defined bands). An out-of-range value therefore scores as the extreme band that owns that end of the scale — e.g.overtime_ratioat 200% (above the top authored value) lands in its worst band, not5.00. There is no "aboveoutstanding.max→5.00" shortcut. -
Null. A
nullraw value (insufficient data / division-by-zero) returnsnulland is excluded from the category aggregate (weight redistributed — see §14).
4.1 Bell-Curve (Target-Band) Metrics
Three metrics are healthiest at a moderate value, not a maximum: overtime_ratio, leave_utilization, and leave_balance. These are encoded with direction: higher but with non-monotonic thresholds so the outstanding band sits in the middle of the value range. Example — overtime_ratio:
| Raw OT/regular % | Band | Score |
|---|---|---|
| 0 – 4.99% | meets | 2.50–3.49 (under-utilized) |
| 5 – 15% | outstanding | 4.50–5.00 (healthy) |
| 15.01 – 25% | exceeds | 3.50–4.49 |
| 25.01 – 35% | needs_improvement | 1.50–2.49 (overwork) |
| 35.01 – 100% | unsatisfactory | 1.00–1.49 (burnout risk) |
Note: This "fold the bands" technique works with the current matcher but is fragile (the band labels no longer correspond to monotonic quality). A first-class
direction: 'target'mode with an explicit ideal range is listed in Appendix B as the recommended hardening.
5. Category 1 — Attendance & Punctuality (ATT)
Code: ATT · Default weight (General Staff): 20% · Dimension focus: Reliability + Wellbeing
Sources: attendance, attendance_metrics, Payslips_model, holidays
Attendance is the foundation of reliability. Note that _get_working_days() excludes weekends (Sat/Sun) and rows from the holidays table, so rates are measured against true scheduled working days, not raw calendar days.
5.1 att_rate — Attendance Rate
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Source | attendance (distinct approved in-days) ÷ _get_working_days() |
| Formula | (days_present / total_working_days) × 100 |
| Thresholds | ≥98 Outstanding · 95–97.99 Exceeds · 90–94.99 Meets · 80–89.99 Needs Imp · <80 Unsatisfactory |
| SMART target | "Maintain ≥95% approved attendance against scheduled working days each review period." |
5.2 punctuality_rate — Punctuality Rate
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Source | attendance ⋈ attendance_metrics — days with late = 0 ÷ total attendance days |
| Formula | (on_time_days / total_attendance_days) × 100 |
| Thresholds | ≥98 · 95–97.99 · 90–94.99 · 80–89.99 · <80 |
| SMART target | "≥95% of attended days with zero recorded tardiness." |
5.3 absence_rate — Absence Rate
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↓ lower · Automated |
| Source | Payslips_model::get_yearly_absent_total() (hours ÷ 8 = days) ÷ working days |
| Formula | (absent_days / total_working_days) × 100 |
| Thresholds (lower better) | 0–1 Outstanding · 1.01–2.5 Exceeds · 2.51–5 Meets · 5.01–8 Needs Imp · >8 Unsatisfactory |
| SMART target | "Keep unpaid absence below 1% of scheduled days per period." |
5.4 overtime_ratio — Overtime Utilization (bell-curve)
| Dimension / Indicator | Wellbeing · Leading |
| Direction / Automation | target-band (encoded higher) · Automated |
| Source | attendance_metrics — SUM(overtime) / SUM(regular_hours) |
| Formula | (overtime_hours / regular_hours) × 100 |
| Healthy band | 5–15% scores highest; <5% under-utilized; >25% burnout risk (see §4.1) |
| SMART target | "Sustain overtime within 5–15% of regular hours; investigate sustained >25%." |
5.5 undertime_rate — Undertime Rate
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↓ lower · Automated |
| Source | attendance_metrics — SUM(undertime) / SUM(regular_hours) |
| Formula | (undertime_hours / regular_hours) × 100 |
| Thresholds (lower better) | 0–0.5 · 0.51–1.5 · 1.51–3 · 3.01–5 · >5 |
| SMART target | "Undertime under 0.5% of regular hours per period." |
6. Category 2 — Task Performance (TSK)
Code: TSK · Default weight (General Staff): 20% · Dimension focus: Quantity + Quality + Reliability
Sources: tasks, activity_logs, kpi_manual_entries
Task assignment uses FIND_IN_SET(user_id, tasks.assigned_to) (comma-separated assignees) and status_id = 3 for completed. Balances volume (task_throughput) against quality (task_quality) and reliability (task_ontime).
6.1 task_completion — Task Completion Rate
| Dimension / Indicator | Quantity / Outcome · Lagging |
| Direction / Automation | ↑ higher · Automated (Tasks_model::get_task_statistics) |
| Formula | (completed_tasks / total_assigned_tasks) × 100 (tasks created in period) |
| Thresholds | ≥95 · 85–94.99 · 70–84.99 · 50–69.99 · <50 |
| SMART target | "Complete ≥85% of tasks assigned during the period." |
6.2 task_ontime — On-Time Task Delivery
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Source/Formula | Intended: completed tasks delivered by deadline ÷ total completed → × 100 |
| Thresholds | ≥90 · 80–89.99 · 65–79.99 · 50–64.99 · <50 |
| SMART target | "Deliver ≥90% of completed tasks on or before deadline." |
⚠️ Returns
null(data limitation, C-10):tasksrecords no completion timestamp, so on-time delivery cannot be computed. Excluded from the composite until acompleted_atexists. See Appendix C.
6.3 task_quality — Task Quality Score (semi-automated)
| Dimension / Indicator | Quality · Lagging |
| Direction / Automation | ↑ higher · Semi-automated (rework detection, manual fallback) |
| Source | tasks ⋈ activity_logs (action = 'reopened'); falls back to kpi_manual_entries when the employee has zero completed tasks in the period |
| Formula | (tasks_without_rework / total_completed) × 100 |
| Thresholds | ≥95 · 85–94.99 · 70–84.99 · 50–69.99 · <50 |
| SMART target | "≤5% of completed tasks reopened/reworked." |
6.4 task_throughput — Task Throughput
| Dimension / Indicator | Quantity · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Formula | completed_tasks / months_in_range (monthly run-rate) |
| Thresholds (count/mo) | ≥20 · 15–19.99 · 10–14.99 · 5–9.99 · <5 |
| SMART target | Role-calibrated — "Sustain ≥15 completed tasks/month" (adjust per role; raw count is volume-sensitive, so set thresholds per template). |
7. Category 3 — Project Delivery (PRJ)
Code: PRJ · Default weight (General Staff): 10% · Dimension focus: Outcome + Reliability
Sources: projects, project_members, tasks
Measures contribution to team delivery, distinct from individual tasks. Project membership resolves via project_members.user_id.
7.1 proj_completion — Project Completion Rate
| Dimension / Indicator | Outcome · Lagging |
| Direction / Automation | ↑ higher · Automated (Projects_model::get_details by team_member_id) |
| Formula | (Σ completed_points / Σ total_points) × 100 across the member's projects |
| Thresholds | ≥95 · 85–94.99 · 70–84.99 · 50–69.99 · <50 |
| SMART target | "Member's projects average ≥85% point completion." |
7.2 proj_ontime — Project On-Time Delivery
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Formula | Intended: completed projects delivered by deadline ÷ total completed → × 100 |
| Thresholds | ≥90 · 80–89.99 · 65–79.99 · 50–64.99 · <50 |
| SMART target | "≥90% of completed projects delivered by deadline." |
⚠️ Returns
null(data limitation, C-10):projectsrecords no completion-date column, so on-time project delivery cannot be computed. Excluded from the composite. See Appendix C.
7.3 proj_contribution — Project Points Contribution
| Dimension / Indicator | Quantity · Lagging |
| Direction / Automation | ↑ higher · Automated (tasks ⋈ project_members) |
| Formula | (user_completed_task_points / total_project_task_points) × 100 |
| Thresholds | ≥30 · 20–29.99 · 10–19.99 · 5–9.99 · <5 |
| SMART target | "Contribute ≥20% of completed task-points on shared projects." |
8. Category 4 — Support & Service Quality (TKT)
Code: TKT · Default weight (General Staff): 10% · Dimension focus: Efficiency + Quality
Sources: tickets, ticket_comments, kpi_manual_entries
Tickets assigned via tickets.assigned_to = user_id; resolved = status = 'closed'.
8.1 ticket_resolved — Ticket Resolution Rate
| Dimension / Indicator | Efficiency / Outcome · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Formula | (resolved_tickets / tickets_in_queue) × 100, where in queue = assigned to the user, created on/before period end, and not closed before the period started |
| Thresholds | ≥95 · 85–94.99 · 70–84.99 · 50–69.99 · <50 |
| SMART target | "Resolve ≥85% of the tickets in your queue during the period." |
✅ Resolved (C-1, Jun 2026): the engine previously emitted a raw count scored against % thresholds; it now computes a true resolution rate. See Appendix C.
8.2 ticket_response — Average Response Time
| Dimension / Indicator | Efficiency · Leading |
| Direction / Automation | ↓ lower · Automated |
| Source/Formula | First comment by the assignee per ticket → AVG(first_response − created_at) in hours |
| Thresholds (hours, lower better) | 0–1 · 1.01–4 · 4.01–8 · 8.01–24 · >24 |
| SMART target | "First response within 1 hour on average." |
8.3 ticket_resolution — Average Resolution Time
| Dimension / Indicator | Efficiency · Lagging |
| Direction / Automation | ↓ lower · Automated |
| Formula | AVG(closed_date − created_at) in hours, closed tickets |
| Thresholds (hours, lower better) | 0–4 · 4.01–12 · 12.01–24 · 24.01–48 · >48 |
| SMART target | "Average resolution under 4 hours." |
8.4 ticket_satisfaction — Ticket Satisfaction (manual)
| Dimension / Indicator | Quality / Outcome · Lagging |
| Direction / Automation | ↑ higher · Manual (kpi_manual_entries) |
| Formula | AVG(manual satisfaction entries), 1–5 scale |
| SMART target | "Maintain ≥4.5/5 average requester satisfaction." Becomes automatable once a ticket-feedback signal exists (Appendix B). |
9. Category 5 — Behavioral Compliance (BHV)
Code: BHV · Default weight (General Staff): 10% · Dimension focus: Compliance
Sources: discipline_entries, leave_applications
This is an inverse category — fewer infractions = higher score. Discipline entries map to a user via FIND_IN_SET(user_id, discipline_entries.employee_ids).
9.1 discipline_score — Disciplinary Score
| Dimension / Indicator | Compliance · Lagging |
| Direction / Automation | ↑ higher (already a 1–5 score) · Automated |
| Formula | MAX(1.00, 5.00 − Σ severity_weight) where verbal=0.25, written=0.50, suspension=1.00, termination=2.50 |
| Thresholds | 4.5–5 · 3.5–4.49 · 2.5–3.49 · 1.5–2.49 · 1–1.49 |
| SMART target | "Zero formal disciplinary actions per period (score 5.00)." |
9.2 leave_compliance — Leave Compliance
| Dimension / Indicator | Compliance · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Formula | (approved_leaves / total_leave_applications) × 100 (defaults to 100 when no applications) |
| Thresholds | ≥98 · 90–97.99 · 80–89.99 · 60–79.99 · <60 |
| SMART target | "≥98% of leave filed through proper approval channels." |
9.3 policy_adherence — Policy Adherence Score (derived)
| Dimension / Indicator | Compliance · Lagging |
| Direction / Automation | ↑ higher · Automated (derived) |
| Formula | (discipline_score × 0.6) + (leave_compliance_score × 0.4) — both on the 1–5 scale |
| SMART target | "Composite conduct score ≥4.5." |
Note:
policy_adherenceis a composite of two other BHV metrics. If all three are enabled in a template, discipline is effectively double-counted; consider disablingpolicy_adherencewherediscipline_score+leave_compliancealready carry the category (see C-4).
10. Category 6 — Leave Management (LVE)
Code: LVE · Default weight (General Staff): 5% · Dimension focus: Wellbeing + Reliability
Sources: leave_credits, leave_applications
Rewards healthy leave behavior — taking enough rest without exhausting entitlements or filing emergencies. Two of three metrics are bell-curve.
10.1 leave_utilization — Leave Credit Utilization (bell-curve)
| Dimension / Indicator | Wellbeing · Leading |
| Direction / Automation | target-band (encoded higher) · Automated |
| Formula | (approved_used_days / total_credits) × 100 for the year |
| Healthy band | 40–70% scores highest; <20% presenteeism risk; >95% entitlement-exhaustion risk |
| SMART target | "Use 40–70% of annual leave credits." |
10.2 unplanned_leave — Unplanned Leave Rate
| Dimension / Indicator | Reliability · Leading |
| Direction / Automation | ↓ lower · Automated |
| Formula | Approved leaves filed on/after their own start date ÷ total approved → × 100 |
| Thresholds (lower better) | 0–5 · 5.01–15 · 15.01–25 · 25.01–40 · >40 |
| SMART target | "Keep same-day/emergency leave under 5% of all leave." |
10.3 leave_balance — Leave Balance Health (bell-curve)
| Dimension / Indicator | Wellbeing · Leading |
| Direction / Automation | target-band (encoded higher) · Automated |
| Formula | (remaining_credits / total_credits) × 100 at period end |
| Healthy band | 30–60% remaining scores highest; very high = hoarding, very low = depleted |
| SMART target | "End the year with 30–60% of credits intact." |
11. Category 7 — Professional Development (PRF)
Code: PRF · Default weight (General Staff): 10% · Dimension focus: Development (all leading indicators)
Sources: Certifications_model, kpi_manual_entries
Development metrics are leading indicators — investment now predicts capability later.
11.1 cert_count — Active Certifications
| Dimension / Indicator | Development · Leading |
| Direction / Automation | ↑ higher · Automated (Certifications_model::get_total_certifications_of_user) |
| Formula | COUNT(active, non-expired certifications) |
| Thresholds (count) | ≥5 · 3–4.99 · 2–2.99 · 1–1.99 · 0 |
| SMART target | "Hold ≥3 role-relevant active certifications." |
11.2 skill_growth — Skill Coverage Ratio (manual)
| Dimension / Indicator | Development · Leading |
| Direction / Automation | ↑ higher · Manual (config + engine now agree) |
| Formula | AVG(manual coverage entries); intended automated form: (matched_skills / role_required_skills) × 100 |
| Thresholds | ≥90 · 75–89.99 · 60–74.99 · 40–59.99 · <40 |
| SMART target | "Cover ≥75% of the role's required skill set." |
✅ Resolved (C-2, Jun 2026): config previously declared this automated while the engine used manual entry; config is now
automated=falseto match. Full automation (role→skillset coverage) is tracked as B-4.
11.3 training_hours — Training Hours (manual)
| Dimension / Indicator | Development · Leading |
| Direction / Automation | ↑ higher · Manual |
| Formula | AVG(manual training-hour entries) |
| Thresholds (hours) | ≥40 · 25–39.99 · 15–24.99 · 5–14.99 · <5 |
| SMART target | "Log ≥25 training hours/year." Automatable via an LMS or training-tagged timesheets (Appendix B). |
11.4 training_completion — Training Completion Rate (manual)
| Dimension / Indicator | Development · Lagging |
| Direction / Automation | ↑ higher · Manual |
| Formula | AVG(manual completion entries) |
| Thresholds | ≥95 · 80–94.99 · 60–79.99 · 40–59.99 · <40 |
| SMART target | "Complete ≥95% of assigned training." |
12. Category 8 — Time Management (TMS)
Code: TMS · Default weight (General Staff): 15% · Dimension focus: Efficiency + Reliability
Sources: Timesheets_model, project_time, attendance, attendance_metrics
12.1 timesheet_util — Timesheet Compliance Rate
| Dimension / Indicator | Reliability · Leading |
| Direction / Automation | ↑ higher · Automated (Timesheets_model::get_summary_details) |
| Formula | (logged_hours / expected_hours) × 100, where expected_hours = working_days × 8 |
| Thresholds | ≥95 · 85–94.99 · 70–84.99 · 50–69.99 · <50 |
| SMART target | "Log ≥95% of expected working hours to timesheets." |
12.2 timesheet_accuracy — Productive Hours Ratio
| Dimension / Indicator | Efficiency · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Source/Formula | Timesheet hours (project_time, duration derived from start/end + manual hours) vs approved attendance hours → (1 − ABS(ts_hours − att_hours) / att_hours) × 100, floored at 0 |
| Thresholds | ≥90 · 80–89.99 · 60–79.99 · 40–59.99 · <40 |
| SMART target | "Logged productive hours within 10% of clocked hours." |
12.3 schedule_adherence — Schedule Adherence
| Dimension / Indicator | Reliability · Lagging |
| Direction / Automation | ↑ higher · Automated |
| Formula | Days with late = 0 AND undertime = 0 ÷ total attendance days → × 100 |
| Thresholds | ≥95 · 85–94.99 · 70–84.99 · 50–69.99 · <50 |
| SMART target | "≥95% of days fully within schedule (no late, no undertime)." |
13. Weight Distribution Templates
Five role-based templates ship as defaults in $config['kpi_template_weights']. Each distributes the 8 category weights to total 100%. Within a category, metric weights (kpi_template_metrics.weight) determine the blend.
| Category | General Staff | Technical / Developer | Customer-Facing | Management | Operations |
|---|---|---|---|---|---|
| ATT — Attendance & Punctuality | 20 | 10 | 25 | 10 | 25 |
| TSK — Task Performance | 20 | 30 | 10 | 15 | 25 |
| PRJ — Project Delivery | 10 | 15 | 5 | 20 | 5 |
| TKT — Support & Service Quality | 10 | 5 | 30 | 10 | 5 |
| BHV — Behavioral Compliance | 10 | 5 | 10 | 10 | 15 |
| LVE — Leave Management | 5 | 5 | 5 | 5 | 5 |
| PRF — Professional Development | 10 | 15 | 5 | 15 | 5 |
| TMS — Time Management | 15 | 15 | 10 | 15 | 15 |
| Total | 100 | 100 | 100 | 100 | 100 |
Template Rules
- All 8 categories carry a weight;
0disables a category for that role. - Category weights must sum to exactly 100% (validated on save).
- Within a category, metric weights are blended via a weighted average over non-null metrics (see §14) — they need not pre-sum to 100; the engine normalizes by the sum of present weights.
- Disabled or null-valued metrics redistribute their weight to the remaining metrics in the category automatically.
- Thresholds may be overridden per template-metric via
override_thresholds(JSON) — used to role-calibrate volume metrics liketask_throughput.
14. Composite Score Calculation
The engine (compute_composite()) computes a two-level weighted average, skipping null metrics at both levels.
14.1 Category Score
category_score = Σ(metric_score × metric_weight) / Σ(metric_weight) ── over non-null metrics only
If every metric in a category is null, the category is omitted entirely and its weight is redistributed.
14.2 Composite Score
composite_score = Σ(category_score × category_weight) / Σ(category_weight) ── over present categories
rating_band = band(composite_score)
Worked example (General Staff):
category_scores: ATT=4.08(20) TSK=3.75(20) PRJ=3.50(10) TKT=4.20(10)
BHV=5.00(10) LVE=3.60(5) PRF=3.00(10) TMS=3.80(15)
composite = (4.08·20 + 3.75·20 + 3.50·10 + 4.20·10 + 5.00·10 + 3.60·5 + 3.00·10 + 3.80·15) / 100
= 388.6 / 100 = 3.89 → exceeds_expectations
14.3 Persistence
compute_composite() returns composite_score, rating_band, category_scores (JSON), metrics_computed, metrics_total, and data_sufficiency_pct. These are saved to kpi_composite_results and snapshotted into kpi_score_history for trend analysis.
15. Rating Bands & Salary Mapping
The composite rating maps to a salary-step recommendation via $config['kpi_default_step_mapping']. Recommendations are advisory and require approval — see 04_SALARY_INTEGRATION.md.
| Rating | Step change | Flags | Consecutive rule |
|---|---|---|---|
outstanding |
+2 | merit_bonus_eligible |
3× → grade_promotion_eligible |
exceeds_expectations |
+1 | — | 3× → extra_step |
meets_expectations |
0 | — | — |
needs_improvement |
0 | pip_triggered |
2× → salary_freeze |
unsatisfactory |
0 | mandatory_pip |
2× → demotion_review |
Step changes are capped by the kpi_salary_max_step_increase setting (default 2). Consecutive-rating detection is provided by Kpi_score_history_model::get_consecutive_rating().
16. Data Sufficiency & Edge Cases
16.1 Data Sufficiency
_check_data_sufficiency() computes coverage = (metrics_total − metrics_null) / metrics_total × 100. Coverage ≥ 60% marks the result data_sufficient = true; below that, the composite is still produced but flagged as low-confidence (surface this in the UI and exclude from rankings).
16.2 Division by Zero / Insufficient Data
Each calculator returns raw_value = null when its denominator is zero (no scheduled days, no tasks, no tickets, no credits). Null metrics are excluded from aggregation and weight is redistributed — they never score 1.00 by default.
16.3 Probationary / New Employees
Employees with < 60% data coverage (typically < ~2 months tenure in the period) should be flagged "Insufficient Data" in the UI and excluded from forced ranking. First-cycle reviews may apply relaxed thresholds via override_thresholds.
16.4 Extended Leave
An employee on leave for the majority of the period will legitimately show null/low operational metrics. Flag "Extended Leave," keep the available metrics for partial assessment, and allow a reviewer override note on the assignment (reviewer_notes, final_override_score).
16.5 Inverse & Bell Metrics
- Inverse (
lower): thresholds inverted at normalization (§4). - Bell (
overtime_ratio,leave_utilization,leave_balance): healthiest in the middle band (§4.1).
Appendix A — Master Metric Register (29 Metrics)
| # | Cat | metric_key |
Label | Dim | Ind | Dir | Auto | Engine method |
|---|---|---|---|---|---|---|---|---|
| 1 | ATT | att_rate |
Attendance Rate | Reliability | Lag | ↑ | ✅ | _calc_attendance_rate |
| 2 | ATT | punctuality_rate |
Punctuality Rate | Reliability | Lag | ↑ | ✅ | _calc_punctuality_rate |
| 3 | ATT | absence_rate |
Absence Rate | Reliability | Lag | ↓ | ✅ | _calc_absence_rate |
| 4 | ATT | overtime_ratio |
Overtime Utilization | Wellbeing | Lead | bell | ✅ | _calc_overtime_ratio |
| 5 | ATT | undertime_rate |
Undertime Rate | Reliability | Lag | ↓ | ✅ | _calc_undertime_rate |
| 6 | TSK | task_completion |
Task Completion Rate | Quantity | Lag | ↑ | ✅ | _calc_task_completion_rate |
| 7 | TSK | task_ontime |
On-Time Task Delivery | Reliability | Lag | ↑ | ✅ | _calc_task_ontime_rate |
| 8 | TSK | task_quality |
Task Quality Score | Quality | Lag | ↑ | ◐ | _calc_task_quality_score |
| 9 | TSK | task_throughput |
Task Throughput | Quantity | Lag | ↑ | ✅ | _calc_task_throughput |
| 10 | PRJ | proj_completion |
Project Completion Rate | Outcome | Lag | ↑ | ✅ | _calc_project_completion |
| 11 | PRJ | proj_ontime |
Project On-Time Delivery | Reliability | Lag | ↑ | ✅ | _calc_project_ontime_rate |
| 12 | PRJ | proj_contribution |
Project Points Contribution | Quantity | Lag | ↑ | ✅ | _calc_project_contribution |
| 13 | TKT | ticket_resolved |
Ticket Resolution Rate | Efficiency | Lag | ↑ | ✅ | _calc_tickets_resolved |
| 14 | TKT | ticket_response |
Avg Response Time | Efficiency | Lead | ↓ | ✅ | _calc_ticket_response_time |
| 15 | TKT | ticket_resolution |
Avg Resolution Time | Efficiency | Lag | ↓ | ✅ | _calc_ticket_resolution_time |
| 16 | TKT | ticket_satisfaction |
Ticket Satisfaction | Quality | Lag | ↑ | ✋ | _calc_ticket_satisfaction |
| 17 | BHV | discipline_score |
Disciplinary Score | Compliance | Lag | ↑ | ✅ | _calc_discipline_score |
| 18 | BHV | leave_compliance |
Leave Compliance | Compliance | Lag | ↑ | ✅ | _calc_leave_compliance |
| 19 | BHV | policy_adherence |
Policy Adherence Score | Compliance | Lag | ↑ | ✅ | _calc_policy_adherence |
| 20 | LVE | leave_utilization |
Leave Credit Utilization | Wellbeing | Lead | bell | ✅ | _calc_leave_utilization |
| 21 | LVE | unplanned_leave |
Unplanned Leave Rate | Reliability | Lead | ↓ | ✅ | _calc_unplanned_leave |
| 22 | LVE | leave_balance |
Leave Balance Health | Wellbeing | Lead | bell | ✅ | _calc_leave_balance |
| 23 | PRF | cert_count |
Active Certifications | Development | Lead | ↑ | ✅ | _calc_certification_count |
| 24 | PRF | skill_growth |
Skill Coverage Ratio | Development | Lead | ↑ | ✋ | _calc_skill_growth (see C-2) |
| 25 | PRF | training_hours |
Training Hours | Development | Lead | ↑ | ✋ | _calc_training_hours |
| 26 | PRF | training_completion |
Training Completion Rate | Development | Lag | ↑ | ✋ | _calc_training_completion |
| 27 | TMS | timesheet_util |
Timesheet Compliance Rate | Reliability | Lead | ↑ | ✅ | _calc_timesheet_utilization |
| 28 | TMS | timesheet_accuracy |
Productive Hours Ratio | Efficiency | Lag | ↑ | ✅ | _calc_timesheet_accuracy |
| 29 | TMS | schedule_adherence |
Schedule Adherence | Reliability | Lag | ↑ | ✅ | _calc_schedule_adherence |
Legend: ✅ Automated · ◐ Semi-automated (auto + manual fallback) · ✋ Manual entry · Lag = lagging indicator · Lead = leading indicator.
Coverage: 24 fully automated · 1 semi-automated (task_quality) · 4 manual (ticket_satisfaction, skill_growth, training_hours, training_completion) = 29.
Appendix B — Best-Practice Enhancements (Future)
These are recommendations, not implemented behavior. They raise the matrix toward best-in-class without changing the current scoring contract. Each can be added as a new metric or a new engine mode behind config.
| # | Enhancement | Best-practice rationale | Effort |
|---|---|---|---|
| B-1 | First-class direction: 'target' mode with an explicit ideal range {ideal_min, ideal_max} and symmetric falloff, replacing the fragile folded-band trick for overtime_ratio, leave_utilization, leave_balance. |
Bell-curve metrics should be expressed as a target, not mislabeled bands; removes misleading band labels. | M |
| B-2 | Goal/objective achievement metric (MBO-style): % of period objectives met, sourced from a lightweight goals table or kpi_manual_entries. |
Outcome alignment is the single highest-leverage KPI per current HR research; pairs lagging results with the employee's own targets. | M–L |
| B-3 | Automate ticket_satisfaction from a post-resolution rating signal (CSAT) once captured on tickets. |
Converts the only customer-quality metric from manual to objective. | M |
| B-4 | Automate skill_growth as role-required-skill coverage via Skillsets_workforce_model (close C-2). |
Removes a manual step and a config/engine contradiction. | S–M |
| B-5 | Automate training_hours from training-tagged timesheets or an LMS module. |
Eliminates manual development data entry. | M |
| B-6 | Peer / 180°–360° input as an optional manual category for management templates. | Best practice blends quantitative + qualitative, multi-rater perspectives. | L |
| B-7 | Period-scoped manual entries: have _get_manual_entry_score() filter by the cycle's date range / cycle_id rather than averaging all-time entries (see C-3). |
Prevents stale prior-period scores leaking into a new review. | S |
| B-8 | eNPS / engagement pulse as an organization-level leading indicator on dashboards (not per-employee scoring). | Engagement is a proven leading indicator of retention and performance. | M |
Appendix C — Implementation Fidelity Notes
Reconciliation items where code and config currently disagree, or where the implementation is incomplete. These are the authoritative to-do list for the next implementation pass; do not "fix" the docs to hide them.
| ID | Item | State | Resolution |
|---|---|---|---|
| C-1 | ticket_resolved count vs rate |
✅ Resolved (Jun 2026) | Engine now computes (resolved / tickets_in_queue) × 100 — a true rate matching the % thresholds. |
| C-2 | skill_growth config/engine mismatch |
✅ Resolved (Jun 2026) | Config flipped to automated=false to match the manual engine behavior. Full automation tracked as B-4. |
| C-3 | Manual-entry scope | ✅ Resolved (Jun 2026) | _get_manual_entry_score() now scopes to the assignment under evaluation (_current_assignment_id), so only the current cycle's entries count. On-demand path still falls back to employee-wide. |
| C-4 | policy_adherence double-counts |
✅ Resolved as documented (Jun 2026) | Confirmed intentional roll-up of discipline_score (0.6) + leave_compliance (0.4). Guidance added at the config source and in §9.3: a template should use either policy_adherence or the discipline_score + leave_compliance pair at full weight — not all three. No engine change (silently altering default scoring would be worse). |
| C-5 | Salary recommendation stub | ✅ Resolved (Jun 2026) | _generate_salary_recommendation() now reads get_current_salary(), prices the recommended step via Salary_grades_model::compute_salary_for_step(), clamps to the grade range, and emits at_grade_ceiling / manual_review_no_grade flags. Approval→salary_history write remains per 04_SALARY_INTEGRATION.md. |
| C-6 | timesheet_accuracy broken query |
✅ Resolved (Jun 2026) | The query referenced a non-existent table (project_time_logs) and a non-existent column (duration), so the metric always errored to null. Now reads project_time and derives duration as TIMESTAMPDIFF(SECOND, start_time, end_time) + ROUND(hours×60)×60, matching Timesheets_model. |
| C-7 | training_hours registry model |
✅ Resolved (Jun 2026) | Removed the unused Timesheets_model from the registry entry (the calculator only reads manual entries). Timesheet/LMS sourcing remains as B-5. |
| C-8 | Metric count | ✅ Resolved (Jun 2026) | Corrected to 29 across docs, the engine docblock, and the config comment. |
| C-9 | Engine SQL referenced columns that don't exist in the real DB | ✅ Resolved (Jun 2026) | A live-schema audit (dash_ahm) found the engine was written against the documented/assumed schema, not the real legacy tables. ~half the metrics queried non-existent columns → threw → silently null. All corrected and validated against the live DB. See the column map below. |
| C-10 | No task/project completion timestamp exists | ✅ Handled (Jun 2026) | tasks and projects have no completed_at/completion_date (and task status changes aren't in activity_logs). task_ontime and proj_ontime cannot be computed; they now return null (excluded from composite) with a documented reason instead of throwing. See Data Limitations below. |
C-9 detail — column corrections (verified against dash_ahm)
| Metric(s) | Was (nonexistent) | Now (real column) |
|---|---|---|
overtime_ratio, timesheet_accuracy |
attendance_metrics.regular_hours, .overtime |
worked, and reg_ot+rest_ot+special_ot+legal_ot |
undertime_rate, schedule_adherence |
attendance_metrics.regular_hours, .undertime |
worked, under |
att_rate, absence_rate, timesheet_util (via _get_working_days) |
holidays.start_date |
holidays.date_from |
ticket_resolved, ticket_resolution |
tickets.closed_date |
tickets.closed_at |
leave_compliance, unplanned_leave |
leave_applications.date_created |
leave_applications.created_at |
task_completion |
tasks.date_created |
tasks.created_date |
task_throughput, task_quality, proj_contribution |
tasks.updated_at (no such column) |
tasks.created_date (period anchor; see Data Limitations) |
discipline_score, policy_adherence |
discipline_entries.employee_ids, .date_created, .severity (all fictional) |
user (CSV), date_occurred; severity-weighting replaced with incident-count scoring (MAX(1, 5 − count)) |
leave_utilization, leave_balance |
leave_credits.total_credits, .year (no such columns) |
credit/debit ledger (action/counts): granted=SUM(debit), used=SUM(credit) |
proj_completion |
Projects_model::get_details(['team_member_id']) (ignored → org-wide) |
['user_id'] (per-employee membership) |
Data Limitations (source data gaps)
These are not bugs — the operational tables simply don't capture the needed signal yet:
- No completion timestamp on tasks/projects.
task_ontimeandproj_ontimereturnnulluntil acompleted_at(tasks) / completion date (projects) is recorded. Recommended: add acompleted_atcolumn set whenstatus_id→3 /status→completed, or log acompletedaction toactivity_logs. This would also lettask_throughput/task_quality/proj_contributionanchor on completion date instead ofcreated_date. task_qualityrework detection. It looks foractivity_logsrows withlog_for='task', action='reopened', but task events aren't currently written toactivity_logs, so rework is always 0 (quality ≈ 100% for anyone with completed tasks). Treat as a manual-leaning metric until task status changes are logged.discipline_scorehas no severity input. Scored purely by incident count (each formal action drops one band). If severity is later added todiscipline_entries, restore severity-weighting.
Security hardening (Jun 2026)
A controller/model security audit found and fixed:
| ID | Severity | Issue | Fix |
|---|---|---|---|
| S-1 | Critical | assignment_detail_modal() had no ownership/manage check — any base-access user could read any colleague's full KPI scores (IDOR), and the unbound id was SQL-injectable |
Added authorization (manager or owner only); id is now int-cast in the model |
| S-2 | High | Every KPI model get_details() interpolated id filters unescaped (SQLi) |
All id-type filters (id, assignment_id, employee_id, cycle_id, template_id, reviewer_id, department_id) cast to (int) at extraction across all 9 models |
| S-3 | Critical | Kpi_employee_assignments_model department filter referenced a nonexistent team_members table/team_id FK |
Rewritten to the project convention: EXISTS (… team dept WHERE dept.id = ? AND FIND_IN_SET(employee_id, dept.members)) |
| S-4 | Medium | overview_data() exposed a company-wide leaderboard (names + scores) to base-access users; the overview tab was shown to everyone |
Endpoint gated with kpi_matrix_manage; overview tab + its AJAX gated to managers in the view; base users land on My KPIs |
Deployment note (D-1) — stale KPI tables in dev DB
The current migration (20260604185300_create_kpi_matrix_tables.php) is the schema source of truth, but each _create_* method short-circuits if the table already exists (SHOW TABLES LIKE … return;). In dash_ahm, only 6 of 9 KPI tables exist and they are from an older schema (e.g. kpi_employee_assignments.user_id/assignment_status, kpi_metric_scores.metric_code, kpi_composite_results.rating_label/data_completeness_pct) — incompatible with the current models/engine (which use employee_id/status, metric_key, rating_band/data_sufficiency_pct). The module cannot run against dash_ahm until the KPI tables are reconciled. Options: (a) drop the 6 stale kpi_* tables and re-run the migration (recreates all 9 with the correct schema; safe only if there is no real KPI data — destructive, requires explicit sign-off), or (b) write an ALTER migration to rename/add the drifted columns. The 3 missing tables (kpi_manual_entries, kpi_score_history, kpi_salary_recommendations) would be created by simply re-running the migration.