Performance Management
Enterprise appraisal lifecycle, multi-evaluator scoring, bell curve analytics, PIP & TNA automation — GB5 PERM module
Vision
PERM replaces the legacy WCF/NHibernate appraisal system with a cloud-native, multi-tenant performance platform on .NET 9. It supports routine, annual, and 360-degree review cycles with configurable KRA/Measure weighting, automated bell curve distribution, PIP auto-creation, TNA generation, and pay revision triggers — all inside a single finalization transaction.
Business Value
Weighted formula ensures objective scoring across all evaluators; removes subjectivity bias
TNA and PIP triggers fire automatically at finalization; no manual HR intervention needed
PayRevisionTriggered flag prevents duplicate salary changes across system restarts
Real-time grade distribution across org, department, or grade — never cached, always live
Scope Boundaries
| PERM Owns | Out of Scope (handled by) |
|---|---|
| KRA, Measure, RatingScale, AppraisalType masters | Goal/OKR tracking → OKR module |
| Appraisal cycle creation & activation | Peer feedback requests → FBCK module |
| Multi-evaluator (Self/Manager/Peer/Subordinate) | Competency profiling → CMGM module |
| Weighted scoring engine + grade mapping | Learning course enrollment → future Learning module |
| PIP plans + action checkpoints | Skill level management → SkillManagement module |
| TNA request generation | |
| Pay revision trigger (via IPayRevisionBLL) | |
| Performance trend time-series | |
| Employee ESS appraisal view |
Key Concepts Glossary
| Term | Definition |
|---|---|
| KRA | Key Result Area — a named performance dimension (e.g., Revenue Target, Customer Satisfaction). Defined in MKRA master. Each KRA has a weightage in a given AppraisalType. |
| Measure | A specific, measurable sub-criterion under a KRA (e.g., "Monthly Revenue Achieved"). Can be Quantitative (numeric target vs achieved) or Qualitative (rating). |
| RatingScale | Grade bands (e.g., A+=90-100%, B=60-70%) stored in MRATINGSCALE. Used to map FinalPercentage to a GradeLetter. |
| AppraisalType | Template defining which KRAs and Measures appear in an appraisal, their weightages, and the Nature (Self/360/Peer). |
| AppraisalPlan | A cycle instance: "FY2026 Annual Review" with PerformanceFrom/To dates, linked to an AppraisalType. One plan generates one TAPPRAISAL per employee. |
| Appraisal (TAPPRAISAL) | One row per employee per plan. Stores consolidated FinalPercentage, grade, increment amounts, and PayRevisionTriggered flag. |
| AppraisalEval | One row per evaluator per appraisal. EvalType: 0=Self, 1=Manager, 2=Peer, 3=Subordinate. |
| FinalPercentage | Weighted consolidation of all evaluator scores: (SelfScore × SelfWeight) + (ManagerScore × (1-SelfWeight)). |
| PIP | Performance Improvement Plan — auto-created when FinalPercentage < PIPTriggerThreshold (default 40%). |
| TNA | Training Needs Analysis — auto-generated when KRAScore < TNAGapThreshold (default 60%). Feeds into future Learning module via Dapr. |
| Bell Curve | Distribution of employee grades across rating bands. Read-only aggregation; never cached. |
| PayRevisionTriggered | Boolean flag on TAPPRAISAL preventing duplicate salary changes on retry/re-finalization. |
| ESS | Employee Self-Service — read-only view for employees of their own appraisal data. EmployeeId always from login.UserId. |
Database Schema — Master Tables
MAPPRAISALTYPE — Appraisal Form Templates
| Column | Type | Notes |
|---|---|---|
| AppraisalTypeId | int PK | |
| Code | nvarchar(20) | Unique per tenant |
| Name | nvarchar(200) | |
| Nature | smallint | 0=Self 1=ReportingTo 2=360 3=Peer |
| Status | smallint | 0=Inactive, 1=Active |
| TenantId | int | Multi-tenant isolation |
MKRA — Key Result Area Master
| Column | Type | Notes |
|---|---|---|
| KRAId | int PK | |
| Code / Name | nvarchar | |
| Nature | smallint | 0=PerformanceBased, 1=PersonalDevelopment |
| TenantId | int |
MMEASURE — Performance Measure Master
| Column | Type | Notes |
|---|---|---|
| MeasureId | int PK | |
| Code / Name | nvarchar | |
| MeasureType | smallint | 0=Quantitative, 1=Qualitative |
| Unit | nvarchar(50) | %, $, count, etc. |
| TenantId | int |
MRATINGSCALE — Grade Bands
| Column | Type | Notes |
|---|---|---|
| RatingScaleId | int PK | |
| GradeLetter / GradeName | nvarchar | e.g., A+, Outstanding |
| SlabFrom / SlabTo | decimal(5,2) | Percentage band. Index on (TenantId, SlabFrom, SlabTo) |
| SortOrder | int |
MAPPRAISALTYPEKRA — KRAs in a Form Template
| Column | Type | Notes |
|---|---|---|
| AppraisalTypeKRAId | int PK | |
| AppraisalTypeId | int FK | |
| KRAId | int FK MKRA | Unique per (AppraisalTypeId, KRAId, TenantId) |
| KRAMeasureWeightage | decimal(5,2) | % weight of Measure scores for this KRA |
| KRAGoalWeightage | decimal(5,2) | % weight of OKR goal progress for this KRA |
| SortOrder | int |
MPERMCONFIG — Configurable Thresholds
| ConfigKey | Default Value | Purpose |
|---|---|---|
| PIPTriggerThreshold | 40 | Auto-create PIP when FinalPercentage below this |
| TNAGapThreshold | 60 | Auto-generate TNA when KRAScore below this |
| SelfWeightDefault | 30 | Self-evaluation weight % in final score consolidation |
Transactional Tables
MAPPRAISALPLAN — Appraisal Cycle
| Column | Type | Notes |
|---|---|---|
| AppraisalPlanId | int PK | |
| Code / Name | nvarchar | e.g., "FY2026-ANNUAL" |
| AppraisalTypeId | int FK | |
| PerformanceFrom / PerformanceTo | date | Performance window |
| EntryFrom / EntryTo | date | Self-evaluation entry window |
| Nature | smallint | Annual / Mid-year / Confirmation |
| Status | smallint | 0=Pending 1=Active 2=Closed 3=Archived |
TAPPRAISAL — Appraisal Instance (one per employee per plan)
| Column | Type | Notes |
|---|---|---|
| AppraisalId | int PK | |
| AppraisalPlanId / EmployeeId / OUID | int | Unique: (AppraisalPlanId, EmployeeId, TenantId) |
| OverallMeasureScore / OverallGoalScore | decimal | Computed by scoring engine |
| FinalPercentage | decimal(5,2) | Consolidated final score 0-100 |
| PercentIncrease / AmountIncrease | decimal | Increment awarded |
| ToDesignationId / ToGradeId | int | Promotion target |
| PayRevisionTriggered | bit | Idempotency flag — prevents duplicate pay revision |
| Status | smallint | 0=Pending 1=Submitted 2=Accepted 3=Rejected 4=Finalized |
TAPPRAISALEVAL — Per-Evaluator Assessment
| Column | Type | Notes |
|---|---|---|
| AppraisalEvalId | int PK | |
| AppraisalId / EvaluatorId | int | |
| EvalType | smallint | 0=Self, 1=Manager, 2=Peer, 3=Subordinate |
| AppraisalAcceptance | smallint | 0=Pending, 1=Accepted, -1=Rejected |
| OverallScore / Remarks / SubmittedOn |
TPIPPLAN — Performance Improvement Plan
| Column | Notes |
|---|---|
| PIPPlanId, EmployeeId, ManagerId | One PIP per underperforming employee |
| StartDate / EndDate | Auto-set: Today to Today+90d on auto-create |
| Reason | Auto-populated from appraisal grade |
| Status | 0=Draft 1=Active 2=Closed 3=Cancelled |
TTNAREQUEST — Training Needs Analysis
| Column | Notes |
|---|---|
| TNARequestId, AppraisalId, EmployeeId, KRAId, MeasureId | One row per KRA that scored below threshold |
| GapScore | TNAGapThreshold - KRAScore (magnitude of gap) |
| TrainingSuggestion | Free-text or auto-populated |
| Status | 0=Pending, 1=SentToLearning, 2=Closed |
Appraisal Lifecycle
Each AppraisalPlan generates one TAPPRAISAL row per employee. Evaluators (self + manager + optional peers) each get a TAPPRAISALEVAL row. The WIP workflow gates SubmitAppraisal. Finalization runs the cascade (scoring → PIP → TNA → PayRevision → Dapr events) inside a single transaction.
Weighted Scoring Formula
KRAMeasureScore = Σ(MeasureScore[i] × MeasureWeightage[i]) / ΣWeightages × KRAMeasureWeightage
KRAGoalScore = GoalCurrentProgress × KRAGoalWeightage
EvalOverallScore = Σ(KRAMeasureScore_weighted) + Σ(KRAGoalScore_weighted)
Consolidation (from MPERMCONFIG.SelfWeightDefault = 30%):
FinalPercentage = (SelfEvalScore × 0.30) + (ManagerEvalScore × 0.70)
Grade Assignment:
SELECT GradeLetter FROM MRATINGSCALE
WHERE FinalPercentage BETWEEN SlabFrom AND SlabTo AND TenantId = @TenantId
IGoalDAL.GetGoalProgressForEmployee(employeeId, okrCycleId, kraId) — a DAL-level cross-module call to OKRDAL avoiding circular BLL references.FinalizeAppraisal Cascade
The most critical flow — runs inside BeginTransactionAsync. All 6 steps must succeed or the entire transaction rolls back.
① CalculateScores
Run weighted scoring engine → store FinalPercentage + GradeLetter on TAPPRAISAL
② PIP Check
If FinalPercentage < MPERMCONFIG['PIPTriggerThreshold'] AND no active TPIPPLAN for employee → auto-create TPIPPLAN (Status=Draft, EndDate=Today+90d) → publish perm.pip.created
③ TNA Check
For each TAPPRAISALEVALKRA where KRAScore < MPERMCONFIG['TNAGapThreshold'] → INSERT TTNAREQUEST (KRAId, GapScore, Status=0) → publish perm.tna.generated
④ Pay Revision Trigger
If PercentIncrease > 0 AND PayRevisionTriggered = false → call IPayRevisionBLL.SavePayRevision(...) → set PayRevisionTriggered = 1. On PayRevision failure: log error, mark TAPPRAISAL.PayRevisionError = true, do NOT abort finalization (re-trigger endpoint available)
⑤ Commit Transaction
All database writes committed atomically
⑥ Publish Dapr Events (outside transaction)
Publish perm.appraisal.finalized and perm.score.finalized to notify Analytics, CMGM
Bell Curve Distribution
-- BellCurveQB.GET_BELL_CURVE_REPORT
SELECT rs.GradeLetter, rs.GradeName,
COUNT(a.AppraisalId) AS EmployeeCount,
CAST(COUNT(a.AppraisalId)*100.0 / SUM(COUNT(a.AppraisalId)) OVER() AS decimal(5,1)) AS Percentage
FROM TAPPRAISAL a
JOIN MRATINGSCALE rs ON a.FinalPercentage BETWEEN rs.SlabFrom AND rs.SlabTo
WHERE a.AppraisalPlanId = @PlanId AND a.TenantId = @TenantId
AND (@OUID = 0 OR a.OUID = @OUID)
AND (@GradeId = 0 OR a.ToGradeId = @GradeId)
GROUP BY rs.GradeLetter, rs.GradeName, rs.SlabFrom
ORDER BY rs.SlabFrom DESC
ESS — Employee Self-Service
GetMyPerformanceSummary is an aggregation endpoint that assembles one unified view from multiple modules:
| Data | Source |
|---|---|
| Current appraisal + KRA scores + grade | PERMDAL direct query |
| Active OKR goals progress | OKR module (TGOAL) |
| Feedback summary (received, pending) | FBCK module via IFeedbackBLL.GetFeedbackSummaryForESS |
| Active PIP status | PERMDAL TPIPPLAN |
| Recent recognitions (last 30 days) | FBCK module TRECOGNITION |
| Unacknowledged memos | FBCK module TMEMO Status=1 |
login.UserId — never from the request body or URL parameter. Passing a different EmployeeId in the request is silently ignored.API Endpoints Reference
Masters
| Method | Route | Description | Cache |
|---|---|---|---|
| GET | /PerfMgmt/KRA/GetKRAList | All KRAs for tenant | CLIENT_LEVEL 10 min |
| POST | /PerfMgmt/KRA/SaveKRA | Create/update KRA | null (invalidates) |
| DELETE | /PerfMgmt/KRA/DeleteKRA | Delete KRA | null |
| GET | /PerfMgmt/Measure/GetMeasureList | All measures | CLIENT_LEVEL 10 min |
| GET | /PerfMgmt/RatingScale/GetRatingScaleList | Grade bands | CLIENT_LEVEL 30 min |
| GET | /PerfMgmt/AppraisalType/GetAppraisalTypeList | Form templates | CLIENT_LEVEL 10 min |
Appraisal Plan (Cycle)
| Method | Route | Description | Cache |
|---|---|---|---|
| GET | /PerfMgmt/AppraisalPlan/GetAppraisalPlanList | All cycles, paged | CLIENT_LEVEL 5 min |
| POST | /PerfMgmt/AppraisalPlan/SaveAppraisalPlan | Create/update plan | null |
| POST | /PerfMgmt/AppraisalPlan/ActivateAppraisalPlan | Activate → generates TAPPRAISAL rows | null |
| POST | /PerfMgmt/AppraisalPlan/CloseAppraisalPlan | Close cycle | null |
Appraisal Instance
| Method | Route | Description | Cache |
|---|---|---|---|
| GET | /PerfMgmt/Appraisal/GetAppraisalList | Paged; filter by PlanId/OUID/Status | NOT_REQUIRED |
| POST | /PerfMgmt/Appraisal/SaveAppraisal | Update appraisal fields | null |
| POST | /PerfMgmt/Appraisal/SubmitAppraisal | Triggers WIP workflow | null |
| POST | /PerfMgmt/Appraisal/AcceptAppraisal | Manager accepts | null |
| POST | /PerfMgmt/Appraisal/RejectAppraisal | Manager rejects with remarks | null |
| POST | /PerfMgmt/Appraisal/FinalizeAppraisal | Runs full cascade (scoring→PIP→TNA→PayRevision) | null |
Analytics & ESS
| Method | Route | Params | Cache |
|---|---|---|---|
| GET | /PerfMgmt/BellCurve/GetBellCurveReport | AppraisalPlanId, GroupBy(0=Org/1=Dept/2=Grade), OUID, GradeId | NOT_REQUIRED |
| GET | /PerfMgmt/Trend/GetPerformanceTrend | EmployeeId, LastNCycles(default 5) | USER_LEVEL |
| GET | /PerfMgmt/ESS/GetMyAppraisal | — | NOT_REQUIRED |
| GET | /PerfMgmt/ESS/GetMyPerformanceSummary | — | NOT_REQUIRED |
Sample Scenarios
Scenario A — Annual Appraisal with 2 KRAs
Plan Setup
| Field | Value |
|---|---|
| Plan Name | FY2026 Annual Review |
| PerformanceFrom | 2025-04-01 |
| PerformanceTo | 2026-03-31 |
| EntryWindow | 2026-04-01 to 2026-04-30 |
| KRA 1 (Revenue Target) | Weight: 60% |
| KRA 2 (Customer Satisfaction) | Weight: 40% |
Scoring Calculation
| Evaluator | Revenue KRA | Customer KRA | Total |
|---|---|---|---|
| Self | 78% | 70% | 74.8% |
| Manager | 72% | 68% | 70.4% |
= 22.44 + 49.28 = 71.72%
Grade: B+ (70–80%) → "Above Average"
Scenario B — Low Performer: PIP Auto-Trigger
-- Employee: Sarah Lee, FinalPercentage = 36% (threshold = 40%)
-- FinalizeAppraisal cascade step 2:
IF @FinalPercentage < 40 AND NOT EXISTS (
SELECT 1 FROM TPIPPLAN WHERE EmployeeId=@EmployeeId AND Status IN (0,1) AND TenantId=@TenantId
)
BEGIN
INSERT INTO TPIPPLAN (EmployeeId, ManagerId, StartDate, EndDate, Reason, Status, TenantId)
VALUES (@EmployeeId, @ManagerId, GETUTCDATE(), DATEADD(d, 90, GETUTCDATE()),
'Below performance threshold (36%)', 0, @TenantId)
-- Dapr publish: perm.pip.created
END
Scenario C — Bell Curve Visualization
FY2026 Annual Review — Grade Distribution (102 employees)
Scenario D — Performance Trend (Last 5 Cycles)
| Cycle | FinalPercentage | Grade | Increment % | Trend |
|---|---|---|---|---|
| FY2022 | 61.2% | B | 5% | — |
| FY2023 | 65.8% | B | 6% | ↑ +4.6% |
| FY2024 | 70.4% | B+ | 8% | ↑ +4.6% |
| FY2025 | 74.1% | B+ | 9% | ↑ +3.7% |
| FY2026 | 71.7% | B+ | 8% | ↓ -2.4% |
Integration Map
/perm/ proxy prefix
SignalR — PermNotificationHub
| Property | Value |
|---|---|
| Group strategy | perm:user:{userId} — per-user (not session) |
| Events pushed | Evaluator submitted, appraisal accepted, appraisal rejected, PIP created |
| Hub type | Hub<IPermNotificationClient> — strongly typed |
| Server push | PermBLL injects IHubContext<PermNotificationHub, IPermNotificationClient> |
Caching Strategy
| Data | Cache Level | TTL | Invalidated By |
|---|---|---|---|
| MAPPRAISALTYPE / MKRA / MMEASURE masters | CLIENT_LEVEL | 10 min | Save, Delete |
| MRATINGSCALE bands | CLIENT_LEVEL | 30 min | Save, Delete |
| MAPPRAISALPLAN list | CLIENT_LEVEL | 5 min | Save, Activate, Close |
| TAPPRAISAL single + TAPPRAISALEVAL | NOT_REQUIRED | — | Live: evaluation changes constantly |
| Bell Curve report | NOT_REQUIRED | — | Live: financial-adjacent HR data |
| Performance Trend per employee | USER_LEVEL | — | On new cycle finalization |
Security & Compliance
Multi-Tenancy
Every SQL query includes AND TenantId = @TenantId (or DatabaseName = @DatabaseName). Cross-tenant data access will never occur by design.
ESS Isolation
All ESS endpoints extract EmployeeId from login.UserId server-side. Request parameters for EmployeeId are ignored — employees cannot view each other's appraisals.
Idempotency
PayRevisionTriggered flag prevents double salary changes. FinalizeAppraisal can be retried safely; only un-triggered pay revisions will fire.
Sensitive Data
LoginDTO fields, salary amounts, and appraisal scores are never logged. Stack traces never returned to client — generic error message only.
Implementation Phases
| Phase | Scope | Duration |
|---|---|---|
| Phase 1 | KRA, Measure, RatingScale, AppraisalType CRUD; constants; MEVENTTYPE seed; legacy master data import | 2 weeks |
| Phase 2 | AppraisalPlan CRUD + status transitions; TAPPRAISAL generation; multi-evaluator CRUD; paged list; WIP config | 2 weeks |
| Phase 3 | CalculateScores weighted algorithm; FinalizeAppraisal; AcceptAppraisal/RejectAppraisal; grade mapping; Bell Curve | 1.5 weeks |
| Phase 4 | FinalizeAppraisal cascade (PIP auto-create, TNA insert, PayRevision trigger); full PIP CRUD; TNA endpoints; Dapr events | 1.5 weeks |
| Phase 5 | GetPerformanceTrend time-series; PermESSBLL aggregation; ESS endpoints; PermNotificationHub SignalR | 1 week |