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 %s 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)