commit 916f4280415ea4633d29b9a18683d0eb1bf08ff1 Author: Suherdy Yacob Date: Thu Feb 5 18:45:49 2026 +0700 first commit diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..0fd2a14 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,26 @@ +{ + 'name': 'Employee KPI', + 'version': '19.0.1.0.0', + 'category': 'Human Resources', + 'summary': 'Manage Employee Key Performance Indicators', + 'description': """ + Employee KPI Module + =================== + Allows employees and managers to track KPIs. + - Create Master KPIs + - Employee "Realization" input + - Automated calculation based on polarization (Max, Min, Min-Max) + """, + 'author': 'Antigravity', + 'depends': ['base', 'hr', 'portal', 'website'], + 'data': [ + 'security/res_groups.xml', + 'security/ir.model.access.csv', + 'views/kpi_views.xml', + 'views/kpi_menus.xml', + 'views/kpi_portal_templates.xml', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a5de2fc Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..de4c645 --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1 @@ +from . import kpi_portal diff --git a/controllers/kpi_portal.py b/controllers/kpi_portal.py new file mode 100644 index 0000000..bf9f95e --- /dev/null +++ b/controllers/kpi_portal.py @@ -0,0 +1,65 @@ +from odoo import http, _ +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager + +class KPIPortal(CustomerPortal): + + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + if 'kpi_count' in counters: + values['kpi_count'] = request.env['kpi.kpi'].search_count([ + ('employee_id.user_id', '=', request.env.user.id) + ]) + return values + + @http.route(['/my/kpi', '/my/kpi/page/'], type='http', auth="user", website=True) + def portal_my_kpis(self, page=1, date_begin=None, date_end=None, sortby=None, **kw): + values = self._prepare_portal_layout_values() + + Kpi = request.env['kpi.kpi'] + domain = [('employee_id.user_id', '=', request.env.user.id)] + + kpi_count = Kpi.search_count(domain) + pager = portal_pager( + url="/my/kpi", + url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby}, + total=kpi_count, + page=page, + step=self._items_per_page + ) + + kpis = Kpi.search(domain, limit=self._items_per_page, offset=pager['offset']) + values.update({ + 'kpis': kpis, + 'page_name': 'kpi', + 'default_url': '/my/kpi', + 'pager': pager, + }) + return request.render("employee_kpi.portal_my_kpis", values) + + @http.route(['/my/kpi/'], type='http', auth="user", website=True) + def portal_my_kpi_detail(self, kpi, **kw): + # Explicit check in case record rules are bypassed or misconfigured + if kpi.employee_id.user_id != request.env.user: + return request.redirect('/my/kpi') + + return request.render("employee_kpi.portal_my_kpi", { + 'kpi': kpi, + 'page_name': 'kpi', + }) + + @http.route(['/my/kpi/save_line'], type='json', auth="user") + def portal_kpi_save_line(self, line_id, realization): + line = request.env['kpi.kpi.line'].browse(line_id) + if line.kpi_id.employee_id.user_id != request.env.user: + return {'error': 'Access Denied'} + + # Only allow edit if KPI is running? Or always? + # Assuming always allow for now, or based on state logic if added later. + line.write({'realization': float(realization)}) + return { + 'success': True, + 'new_score': line.score, + 'new_final_score': line.final_score, + 'kpi_total_score': line.kpi_id.total_score + } diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..2bc83be --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import kpi_kpi +from . import kpi_kpi_line +from . import kpi_period_line diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c880381 Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/kpi_kpi.cpython-312.pyc b/models/__pycache__/kpi_kpi.cpython-312.pyc new file mode 100644 index 0000000..0490052 Binary files /dev/null and b/models/__pycache__/kpi_kpi.cpython-312.pyc differ diff --git a/models/__pycache__/kpi_kpi_line.cpython-312.pyc b/models/__pycache__/kpi_kpi_line.cpython-312.pyc new file mode 100644 index 0000000..ddf5864 Binary files /dev/null and b/models/__pycache__/kpi_kpi_line.cpython-312.pyc differ diff --git a/models/__pycache__/kpi_period_line.cpython-312.pyc b/models/__pycache__/kpi_period_line.cpython-312.pyc new file mode 100644 index 0000000..74fd40a Binary files /dev/null and b/models/__pycache__/kpi_period_line.cpython-312.pyc differ diff --git a/models/kpi_kpi.py b/models/kpi_kpi.py new file mode 100644 index 0000000..eb3e6cf --- /dev/null +++ b/models/kpi_kpi.py @@ -0,0 +1,52 @@ +from odoo import models, fields, api + +class KpiKpi(models.Model): + _name = 'kpi.kpi' + _description = 'Employee KPI' + _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin'] + + name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default='New') + employee_id = fields.Many2one('hr.employee', string='Employee', required=True, tracking=True) + manager_id = fields.Many2one('hr.employee', string='Manager', tracking=True) + job_id = fields.Many2one('hr.job', string='Job Position', related='employee_id.job_id', store=True, readonly=True) + department_id = fields.Many2one('hr.department', string='Department', related='employee_id.department_id', store=True, readonly=True) + + period = fields.Selection([ + ('2024', '2024'), + ('2025', '2025'), + ('2026', '2026'), + ('2027', '2027'), + ], string='Period (Year)', required=True, default=lambda self: str(fields.Date.today().year), tracking=True) + + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('done', 'Done'), + ('cancel', 'Cancelled'), + ], string='Status', default='draft', tracking=True) + + line_ids = fields.One2many('kpi.kpi.line', 'kpi_id', string='KPI Lines') + total_score = fields.Float(string='Total Score', compute='_compute_total_score', store=True) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', 'New') == 'New': + employee = self.env['hr.employee'].browse(vals.get('employee_id')) + year = vals.get('period') + vals['name'] = f"KPI - {employee.name} - {year}" + return super(KpiKpi, self).create(vals_list) + + @api.depends('line_ids.final_score') + def _compute_total_score(self): + for record in self: + record.total_score = sum(line.final_score for line in record.line_ids) + + def action_confirm(self): + self.write({'state': 'confirmed'}) + + def action_done(self): + self.write({'state': 'done'}) + + def action_draft(self): + self.write({'state': 'draft'}) diff --git a/models/kpi_kpi_line.py b/models/kpi_kpi_line.py new file mode 100644 index 0000000..5a1183a --- /dev/null +++ b/models/kpi_kpi_line.py @@ -0,0 +1,272 @@ +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) diff --git a/models/kpi_period_line.py b/models/kpi_period_line.py new file mode 100644 index 0000000..57dcc5e --- /dev/null +++ b/models/kpi_period_line.py @@ -0,0 +1,71 @@ +from odoo import models, fields, api +import logging +from markupsafe import Markup + +_logger = logging.getLogger(__name__) + +class KpiPeriodLine(models.Model): + _name = 'kpi.period.line' + _description = 'KPI Period Line (Worksheet)' + _order = 'sequence, id' + + line_id = fields.Many2one('kpi.kpi.line', string='KPI Line', required=True, ondelete='cascade') + name = fields.Char(string='Period', required=True) + sequence = fields.Integer(string='Sequence', default=10) + + date_start = fields.Date(string='Start Date') + date_end = fields.Date(string='End Date') + + realization = fields.Float(string='Realization', digits=(16, 2)) + target = fields.Float(string='Target (Period)', digits=(16, 2)) + + state = fields.Selection([ + ('draft', 'Open'), + ('closed', 'Closed'), + ], string='Status', default='draft') + + def write(self, vals): + # Track changes for visualization in chatter of the parent KPI + tracked_fields = {'realization', 'target', 'state'} + 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(KpiPeriodLine, 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 + 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: + parent_kpi = rec.line_id.kpi_id + if parent_kpi: + field_label = self._fields[field].string + # Format values for display (mapped selection, etc) not fully implemented here but using raw strings for now is usually fine for floats/simple selects + # For State showing label would be better but raw value is understandable for "draft/closed" + + body = Markup("KPI Line %s - Period %s updated %s: %s → %s") % ( + rec.line_id.name, + rec.name, + field_label, + old_val, + new_val + ) + parent_kpi.message_post(body=body) + return result + + return super(KpiPeriodLine, self).write(vals) diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..746c766 --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_kpi_kpi_line_user,kpi.kpi.line user,model_kpi_kpi_line,group_kpi_user,1,1,1,1 +access_kpi_period_line_user,kpi.period.line user,model_kpi_period_line,group_kpi_user,1,1,1,1 +access_kpi_kpi_manager,kpi.kpi manager,model_kpi_kpi,group_kpi_manager,1,1,1,1 +access_kpi_kpi_line_manager,kpi.kpi.line manager,model_kpi_kpi_line,group_kpi_manager,1,1,1,1 +access_kpi_period_line_manager,kpi.period.line manager,model_kpi_period_line,group_kpi_manager,1,1,1,1 diff --git a/security/ir_rule.xml b/security/ir_rule.xml new file mode 100644 index 0000000..4c810b9 --- /dev/null +++ b/security/ir_rule.xml @@ -0,0 +1,20 @@ + + + + + + See Own KPI + + + [('employee_id.user_id', '=', user.id)] + + + + + Manager See All KPI + + + [(1, '=', 1)] + + + diff --git a/security/res_groups.xml b/security/res_groups.xml new file mode 100644 index 0000000..a1f0349 --- /dev/null +++ b/security/res_groups.xml @@ -0,0 +1,22 @@ + + + + + + Employee KPI + + + + + User + + + + + Manager + + + + + + diff --git a/views/kpi_menus.xml b/views/kpi_menus.xml new file mode 100644 index 0000000..d28750a --- /dev/null +++ b/views/kpi_menus.xml @@ -0,0 +1,34 @@ + + + + + + + My KPIs + kpi.kpi + list,form + [('employee_id.user_id', '=', uid)] + {} + + + + All KPIs + kpi.kpi + list,form + {} + + + + + + + diff --git a/views/kpi_portal_templates.xml b/views/kpi_portal_templates.xml new file mode 100644 index 0000000..9b478cf --- /dev/null +++ b/views/kpi_portal_templates.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + diff --git a/views/kpi_views.xml b/views/kpi_views.xml new file mode 100644 index 0000000..a55e509 --- /dev/null +++ b/views/kpi_views.xml @@ -0,0 +1,168 @@ + + + + + + kpi.kpi.line.form + kpi.kpi.line + +
+
+
+ +
+

+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + + kpi.kpi.line.list + kpi.kpi.line + + + + + + + + + + + + + + + + + + + +