employee_kpi/models/kpi_kpi_line.py
2026-02-05 18:45:49 +07:00

273 lines
11 KiB
Python

from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from markupsafe import Markup
class KpiKpiLine(models.Model):
_name = 'kpi.kpi.line'
_description = 'KPI Line'
kpi_id = fields.Many2one('kpi.kpi', string='KPI Reference', ondelete='cascade')
perspective = fields.Char(string='Perspective', required=True)
code = fields.Char(string='Code')
name = fields.Char(string='KPI Name', required=True)
kpi_type = fields.Selection([
('num', '#'),
('pct', '%'),
('idr', 'IDR')
], string='Type', default='num')
polarization = fields.Selection([
('max', 'Maksimal'),
('min', 'Minimal'),
('range', 'Min-Max'),
], string='Polarization', required=True, default='max')
uom = fields.Selection([
('#', '#'),
('%', '%'),
('IDR', 'IDR'),
('USD', 'USD'),
], string='UoM', default='#')
weight = fields.Float(string='Weight (%)', digits=(16, 2))
period_line_ids = fields.One2many('kpi.period.line', 'line_id', string='Periods')
periodicity = fields.Selection([
('monthly', 'Monthly'),
('quarterly', 'Quarterly'),
('semesterly', 'Semesterly'),
('yearly', 'Yearly'),
], string='Periodicity', default='monthly')
target = fields.Float(string='Target (Full Year)', required=True, default=0.0)
target_ytd = fields.Float(string='Target (YTD)', required=True, default=0.0)
# Thresholds for Min-Max / Range
threshold_min = fields.Float(string='Threshold Min')
target_min = fields.Float(string='Target Min')
target_max = fields.Float(string='Target Max')
threshold_max = fields.Float(string='Threshold Max')
realization = fields.Float(string='Realization', compute='_compute_realization', store=True, readonly=False, digits=(16, 2))
score = fields.Float(string='Score (%)', compute='_compute_score', store=True, digits=(16, 2))
final_score = fields.Float(string='Final Score', compute='_compute_final_score', store=True, digits=(16, 4))
@api.depends('period_line_ids.realization')
def _compute_realization(self):
for line in self:
if line.period_line_ids:
line.realization = sum(p.realization for p in line.period_line_ids)
else:
# Fallback to manual entry if no periods generated (or keep existing value)
# If we want to strictly enforce periodic entry, we would remove this else or make it 0.
# For flexibility, let's allow manual if no periods exist.
pass
def action_open_worksheet(self):
self.ensure_one()
if not isinstance(self.id, int):
from odoo.exceptions import UserError
raise UserError("Please save the KPI header first.")
if not self.period_line_ids:
return self.action_generate_periods()
return {
'type': 'ir.actions.act_window',
'name': 'KPI Worksheet',
'res_model': 'kpi.kpi.line',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref('employee_kpi.view_kpi_kpi_line_form').id,
'target': 'new',
}
def action_generate_periods(self):
self.ensure_one()
if not isinstance(self.id, int):
from odoo.exceptions import UserError
raise UserError("Please save the KPI header first before generating the worksheet.")
self.period_line_ids.unlink()
vals_list = []
try:
year = int(self.kpi_id.period)
except (ValueError, TypeError):
year = fields.Date.today().year
if self.periodicity == 'monthly':
months = [
('January', 1), ('February', 2), ('March', 3), ('April', 4),
('May', 5), ('June', 6), ('July', 7), ('August', 8),
('September', 9), ('October', 10), ('November', 11), ('December', 12)
]
seq = 1
for name, month_idx in months:
vals_list.append({
'line_id': self.id,
'name': name,
'sequence': seq,
'date_start': fields.Date.from_string(f'{year}-{month_idx:02d}-01'),
# Simplified end date logic, improving could use calendar.monthrange
'date_end': fields.Date.from_string(f'{year}-{month_idx:02d}-28'),
})
seq += 1
elif self.periodicity == 'quarterly':
quarters = ['Q1', 'Q2', 'Q3', 'Q4']
seq = 1
for name in quarters:
vals_list.append({
'line_id': self.id,
'name': name,
'sequence': seq,
})
seq += 1
elif self.periodicity == 'semesterly':
semesters = ['Semester 1', 'Semester 2']
seq = 1
for name in semesters:
vals_list.append({
'line_id': self.id,
'name': name,
'sequence': seq,
})
seq += 1
elif self.periodicity == 'yearly':
vals_list.append({
'line_id': self.id,
'name': str(year),
'sequence': 1,
})
if vals_list:
self.env['kpi.period.line'].create(vals_list)
return {
'type': 'ir.actions.act_window',
'name': 'KPI Worksheet',
'res_model': 'kpi.kpi.line',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref('employee_kpi.view_kpi_kpi_line_form').id,
'target': 'new',
}
def action_save_worksheet(self):
""" dummy action to trigger save """
return {'type': 'ir.actions.act_window_close'}
@api.depends('polarization', 'realization', 'target_ytd', 'target_min', 'target_max', 'threshold_min', 'threshold_max')
def _compute_score(self):
for line in self:
score = 0.0
if line.polarization == 'max':
# Higher is better
# Formula assumption based on "Max": (Realization / Target) * 100 ?
# Or simplistic: If Realization >= Target then 100% else relative?
# Usually: (Realization / Target) * 100
if line.target_ytd:
score = (line.realization / line.target_ytd) * 100
# Cap at some point? Usually cap at 100-120% depending on policy, but letting it float for now.
else:
score = 100.0 if line.realization > 0 else 0.0
elif line.polarization == 'min':
# Lower is better.
# Formula: (Target / Realization) * 100 ? Or specialized inverse formula?
# Common inverse: 2 - (Realization / Target) * 100 ?
# Simple assumption: (Target / Realization) * 100
if line.realization and line.realization != 0:
score = (line.target_ytd / line.realization) * 100
else:
score = 100.0 if line.target_ytd == 0 else 0.0
elif line.polarization == 'range': # "Min-Max"
# Logic based on bands:
# Realization between Target Min and Target Max => 100% (or 110% as per some KPI structures)
# Realization between Threshold Min and Target Min => Pro-rated?
# Realization between Target Max and Threshold Max => Pro-rated?
# Outside Threshold => 0
real = line.realization
# Perfect Range
if line.target_min <= real <= line.target_max:
score = 100.0
else:
# Check lower band
if line.threshold_min <= real < line.target_min:
# Linear interpolation from Threshold(0%) to TargetMin(100%)
if (line.target_min - line.threshold_min) != 0:
score = ((real - line.threshold_min) / (line.target_min - line.threshold_min)) * 100
else:
score = 0.0
# Check upper band
elif line.target_max < real <= line.threshold_max:
# Linear interpolation from TargetMax(100%) to ThresholdMax(0%)
if (line.threshold_max - line.target_max) != 0:
score = ((line.threshold_max - real) / (line.threshold_max - line.target_max)) * 100
else:
score = 0.0
else:
score = 0.0
line.score = score
@api.depends('score', 'weight')
def _compute_final_score(self):
for line in self:
# Weight is likely in percentage (e.g. 10 or 0.1). Excel showed 0.05 (5%).
# If weight is 0.05, and Score is 100. Final Score should be 5.
# So: Score * Weight if Weight is 0.xx, or Score * (Weight/100) if Weight is xx.
# User excel showed: Weight 0.05. Score 100. Final Score = 0.05 * 100 = 5? Or 0.05 * 100 = 5.
# Let's assume Weight is entered as decimal (0.05) as per Excel "0.05".
line.final_score = line.score * line.weight
def write(self, vals):
# Track changes for visualization in chatter
tracked_fields = {'realization', 'target', 'target_ytd', 'threshold_min', 'threshold_max', 'target_min', 'target_max'}
fields_to_check = tracked_fields.intersection(vals.keys())
if fields_to_check:
# Store old values to compare
old_values = {rec.id: {f: rec[f] for f in fields_to_check} for rec in self}
# Perform write
result = super(KpiKpiLine, self).write(vals)
# Check and log
for rec in self:
for field in fields_to_check:
old_val = old_values[rec.id].get(field)
new_val = vals.get(field)
# Handle comparisons safely (simplified for float as most are float)
changed = False
if self._fields[field].type == 'float':
if float(old_val or 0.0) != float(new_val or 0.0):
changed = True
else:
if old_val != new_val:
changed = True
if changed:
field_label = self._fields[field].string
body = Markup("KPI Line <b>%s</b> updated %s: %s &rarr; %s") % (
rec.name,
field_label,
old_val,
new_val
)
rec.kpi_id.message_post(body=body)
return result
return super(KpiKpiLine, self).write(vals)