End-to-end workflow tightening + the team / skills system. Three
phases bundled because they share the same touchpoints (button_start /
button_finish / Manager Desk dropdown).
PHASE 1 — In-Odoo notifications + timer audit
=============================================
Workers now get a bell-icon notification (Odoo Discuss inbox) the
moment a manager assigns them a WO. No email — operators check Discuss
between jobs, and the customer-facing notification dispatcher stays
out of the worker loop.
- mrp.workorder.write() override fires message_notify(message_type=
'user_notification') only when x_fc_assigned_user_id transitions to
a non-empty value (clearing or no-op writes don't ping)
- 4 new fields on the WO header surface what was previously buried in
time_ids: x_fc_started_by_user_id, x_fc_started_at,
x_fc_finished_by_user_id, x_fc_finished_at
- button_start stamps started_* once (subsequent pause/resume cycles
preserve the original); button_finish stamps finished_* every time
the WO closes
- New "Timer Audit" group on the WO form (Time & Cost tab)
PHASE 2 — Presence-aware Manager Desk
=====================================
Manager Desk now knows who's clocked in. Works with vanilla
hr_attendance and fusion_clock — both expose hr.attendance with an
open record while the operator is on shift.
- bridge_mrp depends on hr_attendance
- hr.employee.x_fc_is_clocked_in computed field (batched query — one
DB hit for the whole employee set, not N+1)
- hr.employee._fp_clocked_in_user_ids() classmethod for the dashboard
- manager_controller sends operators with is_clocked_in / role_ids /
lead_hand_role_ids per worker, plus presence dict {clocked_in: N,
total: M}; each WO carries role_id/role_name so the dropdown can
match qualified operators
Manager Desk OWL:
- Header gets a "Present 7 / 12" pill chip; tap to toggle hideOffShift
(off-shift hidden when active, accent colour when filter is on)
- New operatorsForWO(wo) helper sorts dropdown options into 4 buckets:
qualified+clocked-in → lead-hand+clocked-in → clocked-in untrained
(training mode) → off-shift (greyed; only shown when hideOffShift
is false). Each option carries a ●/○ dot prefix and a soft suffix.
PHASE 3 — Skills, lead-hand-per-role, auto-promotion
====================================================
The team grows organically: managers assign training tasks, operators
finish them, the system auto-promotes after N successful runs.
- fp.work.role.mastery_required (integer, default reads from the
company-level Default Mastery Threshold). Each role can override —
masking might need 1 success, electroless nickel 5.
- res.company.x_fc_default_mastery_threshold + res.config.settings
exposure under "Workforce Settings" in the Fusion Plating settings
block (default 3)
- hr.employee.x_fc_lead_hand_role_ids m2m, separate from
x_fc_work_role_ids — Sarah can be a lead hand for masking + racking
even if those aren't her primary roles. Manager-only group access.
- New fp.operator.proficiency model (one row per employee+role) with
completed_count, first/last_completed_at, promoted, promoted_at,
progress_label compute. SQL-unique on (employee, role).
- mrp.workorder.button_finish increments the (employee, role)
counter, then if count >= role.mastery_required AND not promoted,
adds the role to x_fc_work_role_ids and posts a "🎉 Promoted"
chatter line on the employee record. Wrapped in try/except so a
tracker glitch never blocks production.
- Promotion uses the WO's assigned_user_id, NOT env.user — credit
goes to the operator who was supposed to do it, even if a manager
finished on their behalf.
Employee form gets a "Shop Roles" tab (supervisor+):
- "Tasks This Operator Can Do" m2m
- "Lead Hand For" m2m (manager-only)
- Read-only Task Proficiency list with progress / promotion badges
Verified on odoo-entech: all fields land, default threshold = 3,
asset bundle regenerated as 9f38f05.
Module bumps: fusion_plating 19.0.4.0.0,
fusion_plating_bridge_mrp 19.0.4.0.0,
fusion_plating_shopfloor 19.0.11.0.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
6.3 KiB
Python
173 lines
6.3 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.
|
|
"""Operator proficiency tracker — counts successful WO 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.
|
|
"""
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
|
|
class FpOperatorProficiency(models.Model):
|
|
_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 '
|
|
'WO that required this role.',
|
|
)
|
|
first_completed_at = fields.Datetime(
|
|
string='First Success',
|
|
help='When the operator finished their first WO for this role.',
|
|
)
|
|
last_completed_at = fields.Datetime(
|
|
string='Last Success',
|
|
help='Most recent WO 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 used by mrp.workorder.button_finish (via _fp_record_proficiency).
|
|
# ------------------------------------------------------------------
|
|
@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):
|
|
"""Promote the employee if they've crossed the role's threshold.
|
|
|
|
- Already promoted: no-op (history is preserved but no duplicate
|
|
chatter spam).
|
|
- Already in Shop Roles (e.g. manager added it manually): mark
|
|
promoted but don't post chatter.
|
|
- Below threshold: nothing to do.
|
|
- At/above threshold AND not on Shop Roles yet: add the role and
|
|
post a celebratory chatter line on the employee.
|
|
"""
|
|
for rec in self:
|
|
if rec.promoted:
|
|
continue
|
|
target = rec.role_id.mastery_required or 0
|
|
if target <= 0:
|
|
continue # Auto-promotion disabled for this role
|
|
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:
|
|
# Manager pre-added the role; don't double-announce.
|
|
continue
|
|
# Add to Shop Roles + announce on the employee chatter.
|
|
employee.sudo().write({
|
|
'x_fc_work_role_ids': [(4, role.id)],
|
|
})
|
|
employee.message_post(
|
|
body=_(
|
|
'🎉 <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',
|
|
)
|