273 lines
11 KiB
Python
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 → %s") % (
|
|
rec.name,
|
|
field_label,
|
|
old_val,
|
|
new_val
|
|
)
|
|
rec.kpi_id.message_post(body=body)
|
|
return result
|
|
|
|
return super(KpiKpiLine, self).write(vals)
|