164 lines
5.7 KiB
Python
164 lines
5.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
# Part of the Fusion Plating product family.
|
|
#
|
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
|
|
# never had MRP fields; the bridge module was just its initial home.
|
|
|
|
from markupsafe import Markup
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
|
|
class FpOperatorProficiency(models.Model):
|
|
"""Operator proficiency tracker — counts successful step completions
|
|
per (employee, role) pair and auto-promotes the employee once the
|
|
role's mastery threshold is crossed.
|
|
|
|
The promotion mechanic lets managers casually train workers on the job:
|
|
they assign someone a task they've never done, the worker finishes it
|
|
successfully, and after N successes the role is added to the
|
|
employee's Shop Roles automatically. The operator never has to fill
|
|
in a form; their growing skill set just unlocks itself.
|
|
"""
|
|
_name = 'fp.operator.proficiency'
|
|
_description = 'Fusion Plating — Operator Task Proficiency'
|
|
_rec_name = 'display_name'
|
|
_order = 'employee_id, role_id'
|
|
|
|
employee_id = fields.Many2one(
|
|
'hr.employee', string='Operator',
|
|
required=True, ondelete='cascade', index=True,
|
|
)
|
|
role_id = fields.Many2one(
|
|
'fp.work.role', string='Role',
|
|
required=True, ondelete='cascade', index=True,
|
|
)
|
|
completed_count = fields.Integer(
|
|
string='Completions',
|
|
default=0,
|
|
help='Number of times this operator has successfully finished a '
|
|
'step that required this role.',
|
|
)
|
|
first_completed_at = fields.Datetime(
|
|
string='First Success',
|
|
help='When the operator finished their first step for this role.',
|
|
)
|
|
last_completed_at = fields.Datetime(
|
|
string='Last Success',
|
|
help='Most recent step completion against this role.',
|
|
)
|
|
promoted = fields.Boolean(
|
|
string='Promoted',
|
|
default=False,
|
|
index=True,
|
|
help='True once the role has been added to the operator\'s Shop '
|
|
'Roles automatically. Stays True even if a manager removes '
|
|
'the role afterwards — the count and promotion history are '
|
|
'preserved as a training record.',
|
|
)
|
|
promoted_at = fields.Datetime(
|
|
string='Promoted On',
|
|
help='When the auto-promotion fired (count crossed the role\'s '
|
|
'mastery threshold).',
|
|
)
|
|
|
|
display_name = fields.Char(
|
|
compute='_compute_display_name', store=True,
|
|
)
|
|
progress_label = fields.Char(
|
|
compute='_compute_progress_label',
|
|
help='"3 / 5" style indicator of how close this operator is to '
|
|
'mastery.',
|
|
)
|
|
|
|
_sql_constraints = [
|
|
('fp_proficiency_uniq',
|
|
'unique(employee_id, role_id)',
|
|
'There is already a proficiency record for this operator and role.'),
|
|
]
|
|
|
|
@api.depends('employee_id.name', 'role_id.name')
|
|
def _compute_display_name(self):
|
|
for rec in self:
|
|
rec.display_name = (
|
|
f'{rec.employee_id.name or "?"} — {rec.role_id.name or "?"}'
|
|
)
|
|
|
|
@api.depends('completed_count', 'role_id.mastery_required')
|
|
def _compute_progress_label(self):
|
|
for rec in self:
|
|
target = rec.role_id.mastery_required or 0
|
|
rec.progress_label = (
|
|
f'{rec.completed_count} / {target}' if target
|
|
else str(rec.completed_count)
|
|
)
|
|
|
|
@api.model
|
|
def _record_completion(self, employee, role):
|
|
"""Increment the (employee, role) tally and promote if at threshold.
|
|
|
|
Idempotent for the (employee, role) pair — if no record exists,
|
|
we create one. Always uses sudo() because the worker may not
|
|
have write access to their own profile.
|
|
"""
|
|
if not employee or not role:
|
|
return self.browse()
|
|
|
|
rec = self.sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('role_id', '=', role.id),
|
|
], limit=1)
|
|
now = fields.Datetime.now()
|
|
if rec:
|
|
new_count = rec.completed_count + 1
|
|
rec.write({
|
|
'completed_count': new_count,
|
|
'last_completed_at': now,
|
|
})
|
|
else:
|
|
rec = self.sudo().create({
|
|
'employee_id': employee.id,
|
|
'role_id': role.id,
|
|
'completed_count': 1,
|
|
'first_completed_at': now,
|
|
'last_completed_at': now,
|
|
})
|
|
rec._maybe_promote()
|
|
return rec
|
|
|
|
def _maybe_promote(self):
|
|
for rec in self:
|
|
if rec.promoted:
|
|
continue
|
|
target = rec.role_id.mastery_required or 0
|
|
if target <= 0:
|
|
continue
|
|
if rec.completed_count < target:
|
|
continue
|
|
employee = rec.employee_id
|
|
role = rec.role_id
|
|
already_assigned = role in employee.x_fc_work_role_ids
|
|
rec.sudo().write({
|
|
'promoted': True,
|
|
'promoted_at': fields.Datetime.now(),
|
|
})
|
|
if already_assigned:
|
|
continue
|
|
employee.sudo().write({
|
|
'x_fc_work_role_ids': [(4, role.id)],
|
|
})
|
|
employee.message_post(
|
|
body=Markup(_(
|
|
'<b>%(name)s promoted</b> — qualified for '
|
|
'<b>%(role)s</b> after %(count)s successful '
|
|
'completions.'
|
|
)) % {
|
|
'name': employee.name,
|
|
'role': role.name,
|
|
'count': rec.completed_count,
|
|
},
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|