diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index cda664c7..d71ab89f 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
- 'version': '19.0.3.0.0',
+ 'version': '19.0.4.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
diff --git a/fusion_plating/fusion_plating/models/res_company.py b/fusion_plating/fusion_plating/models/res_company.py
index 45965ac4..e61495f0 100644
--- a/fusion_plating/fusion_plating/models/res_company.py
+++ b/fusion_plating/fusion_plating/models/res_company.py
@@ -29,6 +29,20 @@ class ResCompany(models.Model):
'Settings > Fusion Plating.',
)
+ # ----- Worker auto-promotion default -----------------------------------
+ # Default number of successful WO completions a worker needs on a role
+ # before it's auto-added to their Shop Roles. Each role can override
+ # via fp.work.role.mastery_required.
+ x_fc_default_mastery_threshold = fields.Integer(
+ string='Default Mastery Threshold',
+ default=3,
+ help='How many successful WO completions an operator needs on a '
+ "task before it's added to their Shop Roles automatically. "
+ 'New roles inherit this number; managers can override per '
+ 'role on the role form. 1 = promote on first success; 3 = '
+ 'solid baseline; 5+ for tasks that need real practice.',
+ )
+
# ----- Facility footprint for this legal entity ----------------------
x_fc_facility_ids = fields.One2many(
'fusion.plating.facility',
diff --git a/fusion_plating/fusion_plating/models/res_config_settings.py b/fusion_plating/fusion_plating/models/res_config_settings.py
index 79de409a..c9a7b665 100644
--- a/fusion_plating/fusion_plating/models/res_config_settings.py
+++ b/fusion_plating/fusion_plating/models/res_config_settings.py
@@ -20,3 +20,8 @@ class ResConfigSettings(models.TransientModel):
readonly=False,
string='Fusion Plating Timezone',
)
+ x_fc_default_mastery_threshold = fields.Integer(
+ related='company_id.x_fc_default_mastery_threshold',
+ readonly=False,
+ string='Default Mastery Threshold',
+ )
diff --git a/fusion_plating/fusion_plating/views/res_config_settings_views.xml b/fusion_plating/fusion_plating/views/res_config_settings_views.xml
index 99b812bd..0dc6df66 100644
--- a/fusion_plating/fusion_plating/views/res_config_settings_views.xml
+++ b/fusion_plating/fusion_plating/views/res_config_settings_views.xml
@@ -27,6 +27,16 @@
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
index 2a087688..ea884050 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — MRP Bridge',
- 'version': '19.0.3.0.0',
+ 'version': '19.0.4.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
@@ -42,6 +42,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor',
'fusion_plating_configurator',
'hr',
+ # hr_attendance gives us the standard hr.attendance model
+ # (check_in / check_out). fusion_clock builds on the same model
+ # so this works whether the shop runs vanilla attendance or the
+ # full Fusion Clock T&A. Bringing the dep into the bridge keeps
+ # the Manager Desk's "show only clocked-in workers" filter
+ # working out of the box.
+ 'hr_attendance',
'mrp',
'mrp_workorder',
'mrp_account',
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py
index d2015fef..6009e406 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py
@@ -17,4 +17,5 @@ from . import account_move
from . import sale_order
from . import fp_work_role
from . import hr_employee
+from . import fp_proficiency
from . import fp_process_node
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py
new file mode 100644
index 00000000..e8ca3437
--- /dev/null
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_proficiency.py
@@ -0,0 +1,172 @@
+# -*- 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=_(
+ '🎉 %(name)s promoted — qualified for '
+ '%(role)s after %(count)s successful '
+ 'completions.',
+ name=employee.name,
+ role=role.name,
+ count=rec.completed_count,
+ ),
+ subtype_xmlid='mail.mt_note',
+ )
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py
index 7d9e32e9..c627a0a0 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
-from odoo import fields, models
+from odoo import api, fields, models
class FpWorkRole(models.Model):
@@ -43,7 +43,25 @@ class FpWorkRole(models.Model):
)
active = fields.Boolean(default=True)
- _sql_constraints = [
- ('fp_work_role_code_uniq', 'unique(code)',
- 'Role code must be unique.'),
- ]
+ # ------------------------------------------------------------------
+ # Mastery threshold — how many successful WO completions a worker
+ # needs on this role before they're auto-promoted (added to their
+ # x_fc_work_role_ids). Default reads from the company-level Fusion
+ # Plating settings so a new role inherits the shop default; the
+ # manager can override per role for tasks that need more practice
+ # (e.g. masking = 1, electroless nickel plating = 5).
+ # ------------------------------------------------------------------
+ mastery_required = fields.Integer(
+ string='Mastery Threshold',
+ default=lambda self: self._default_mastery_required(),
+ help='Number of successful WO completions a worker needs on this '
+ "role before they're added to its qualified-operators list "
+ 'automatically. 1 = promote on first success; 3 = solid '
+ "default for everyday roles; 5+ for tasks that need real "
+ 'practice. Defaults from Settings > Fusion Plating > '
+ 'Default Mastery Threshold.',
+ )
+
+ @api.model
+ def _default_mastery_required(self):
+ return self.env.company.x_fc_default_mastery_threshold or 3
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py
index bf5d4acc..f766cbe6 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
-from odoo import fields, models
+from odoo import api, fields, models
class HrEmployee(models.Model):
@@ -13,6 +13,12 @@ class HrEmployee(models.Model):
are generated; an employee with multiple roles receives WOs for all
of them. A small shop where the owner wears every hat just tags
themselves with every role.
+
+ Lead hands are a separate per-role list — they don't have to be
+ primary owners of those roles, but they're authorised to step in
+ when the regular owner is absent or behind. The Manager Desk
+ promotes lead hands above other workers in its dropdown for any
+ role they cover.
"""
_inherit = 'hr.employee'
@@ -20,5 +26,94 @@ class HrEmployee(models.Model):
'fp.work.role', 'fp_employee_work_role_rel',
'employee_id', 'role_id', string='Shop Roles',
help='Which shop roles this employee performs. Used by the '
- 'Manager Desk and auto-assignment on WO generation.',
+ 'Manager Desk and auto-assignment on WO generation. '
+ 'Roles are added automatically when an employee completes '
+ 'a task that meets the role mastery threshold.',
)
+ # Per-role lead-hand list. Sarah might be a lead hand for masking +
+ # racking but not for plating; Mike might cover everything during
+ # a graveyard shift. Stored on a separate relation table so the
+ # primary "Shop Roles" list stays distinct from the cover-anything
+ # authority.
+ x_fc_lead_hand_role_ids = fields.Many2many(
+ 'fp.work.role', 'fp_employee_lead_hand_role_rel',
+ 'employee_id', 'role_id', string='Lead Hand For',
+ help='Roles where this employee is authorised to lead or cover '
+ 'for an absent operator. Lead hands are surfaced first in '
+ 'the Manager Desk worker picker for these roles.',
+ )
+
+ x_fc_proficiency_ids = fields.One2many(
+ 'fp.operator.proficiency', 'employee_id',
+ string='Task Proficiency',
+ help='Per-role completion tally. Workers earn one count per WO '
+ 'they finish on a given role. Once the count crosses the '
+ "role's mastery threshold the role is added to their "
+ 'Shop Roles list automatically.',
+ )
+
+ # ------------------------------------------------------------------
+ # Attendance helpers — used by the Manager Desk to show who is
+ # currently clocked in. Works with vanilla hr_attendance or the
+ # full fusion_clock module — both store an open record (no
+ # check_out) for as long as the employee is on shift.
+ # ------------------------------------------------------------------
+ x_fc_is_clocked_in = fields.Boolean(
+ string='Clocked In',
+ compute='_compute_x_fc_is_clocked_in',
+ search='_search_x_fc_is_clocked_in',
+ help='True if this employee currently has an open hr.attendance '
+ 'record (clocked in but not clocked out).',
+ )
+
+ def _compute_x_fc_is_clocked_in(self):
+ """Compute attendance status from hr.attendance.
+
+ Batched so the manager dashboard doesn't issue one query per
+ employee — important when the shop has dozens of operators.
+ """
+ if not self:
+ return
+ Att = self.env.get('hr.attendance')
+ if Att is None:
+ for emp in self:
+ emp.x_fc_is_clocked_in = False
+ return
+ # One read for the whole recordset.
+ open_emp_ids = set(Att.sudo().search([
+ ('employee_id', 'in', self.ids),
+ ('check_out', '=', False),
+ ]).mapped('employee_id').ids)
+ for emp in self:
+ emp.x_fc_is_clocked_in = emp.id in open_emp_ids
+
+ def _search_x_fc_is_clocked_in(self, operator, value):
+ """Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain."""
+ Att = self.env.get('hr.attendance')
+ if Att is None:
+ return [('id', '=', False)]
+ open_ids = Att.sudo().search([
+ ('check_out', '=', False),
+ ]).mapped('employee_id').ids
+ if operator in ('=', '!='):
+ wanted = bool(value)
+ if operator == '!=':
+ wanted = not wanted
+ return [('id', 'in' if wanted else 'not in', open_ids)]
+ return []
+
+ @api.model
+ def _fp_clocked_in_user_ids(self):
+ """Return the set of res.users.ids whose linked employee is on shift.
+
+ Used by the Manager Desk controller to short-circuit the worker
+ dropdown to "present today" without an N+1 attendance query
+ per worker.
+ """
+ Att = self.env.get('hr.attendance')
+ if Att is None:
+ return set()
+ emps = Att.sudo().search([
+ ('check_out', '=', False),
+ ]).mapped('employee_id')
+ return set(emps.user_id.ids)
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
index eefa3152..9bd57f1c 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
@@ -70,6 +70,34 @@ class MrpWorkorder(models.Model):
'recipe operation on WO generation).',
)
+ # ------------------------------------------------------------------
+ # Timer audit — surface the who / when of the timer on the WO header.
+ # Odoo records every start/stop in mrp.workcenter.productivity but
+ # the operator + manager need to see "started by Sarah at 09:14,
+ # finished by Sarah at 11:42" without drilling into time_ids.
+ # Populated by the button_start / button_finish overrides below.
+ # ------------------------------------------------------------------
+ x_fc_started_by_user_id = fields.Many2one(
+ 'res.users', string='Started By',
+ readonly=True, copy=False,
+ help='The operator who first hit Start on this work order.',
+ )
+ x_fc_started_at = fields.Datetime(
+ string='Started At',
+ readonly=True, copy=False,
+ help='Wall-clock time the timer first started running.',
+ )
+ x_fc_finished_by_user_id = fields.Many2one(
+ 'res.users', string='Finished By',
+ readonly=True, copy=False,
+ help='The operator who hit Finish to close the WO.',
+ )
+ x_fc_finished_at = fields.Datetime(
+ string='Finished At',
+ readonly=True, copy=False,
+ help='Wall-clock time the timer was closed for the last time.',
+ )
+
# ------------------------------------------------------------------
# Workflow step tracking
# ------------------------------------------------------------------
@@ -420,6 +448,68 @@ class MrpWorkorder(models.Model):
})
return {'holds': holds, 'ncrs': ncrs}
+ # ------------------------------------------------------------------
+ # write() — fire an in-Odoo notification when a worker is assigned.
+ # Email is intentionally NOT sent here; the operator gets a bell-icon
+ # ping in Odoo Discuss the moment the manager picks them. The
+ # fp.notification.template hooks still send emails for customer-facing
+ # events, but worker assignment is internal.
+ # ------------------------------------------------------------------
+ def write(self, vals):
+ # Snapshot the previous assignee so we know if it actually changed.
+ # We only notify on a real change to a non-empty value (clearing
+ # the field doesn't deserve a ping).
+ previous = {wo.id: wo.x_fc_assigned_user_id.id for wo in self}
+ res = super().write(vals)
+ if 'x_fc_assigned_user_id' in vals:
+ for wo in self:
+ new_id = wo.x_fc_assigned_user_id.id
+ if new_id and new_id != previous.get(wo.id):
+ wo._fp_notify_assignee()
+ return res
+
+ def _fp_notify_assignee(self):
+ """Send a bell-icon notification to the newly-assigned operator.
+
+ Uses message_type='user_notification' which routes to the user's
+ Inbox in Discuss without creating a chatter entry on the record
+ (Odoo treats it as a transient ping). The body is intentionally
+ terse — operators read these on a tablet between jobs.
+ """
+ for wo in self:
+ user = wo.x_fc_assigned_user_id
+ if not user or not user.partner_id:
+ continue
+ mo = wo.production_id
+ customer = wo.x_fc_customer_id.name if wo.x_fc_customer_id else ''
+ product = (
+ mo.product_id.display_name if mo and mo.product_id else ''
+ )
+ qty = int(mo.product_qty or 0) if mo else 0
+ wc = wo.workcenter_id.name or ''
+ role = wo.x_fc_work_role_id.name or ''
+
+ # Build a short, scannable body
+ lines = [
+ _('You have been assigned %s.', wo.display_name or wo.name),
+ _('MO: %s · %s · Qty %s', mo.name if mo else '—', product, qty),
+ ]
+ if wc:
+ lines.append(_('Work centre: %s', wc))
+ if role:
+ lines.append(_('Role: %s', role))
+ if customer:
+ lines.append(_('Customer: %s', customer))
+ body = ' '.join(lines)
+
+ wo.message_notify(
+ partner_ids=user.partner_id.ids,
+ subject=_('Work order assigned — %s', wo.display_name or wo.name),
+ body=body,
+ # Inbox-only ping; no chatter post, no email.
+ email_layout_xmlid=False,
+ )
+
# ------------------------------------------------------------------
# T2.2 — Certification gate on WO start
# ------------------------------------------------------------------
@@ -427,7 +517,20 @@ class MrpWorkorder(models.Model):
"""Block start unless the current user's linked employee holds
an active certification for this WO's process type."""
self._fp_check_operator_certification()
- return super().button_start()
+ res = super().button_start()
+ # Capture audit AFTER the super call so we don't stamp WOs that
+ # the cert gate (or any other downstream check) rejected.
+ now = fields.Datetime.now()
+ uid = self.env.user.id
+ for wo in self:
+ # Only stamp the first time — subsequent pause/resume cycles
+ # shouldn't overwrite the original start.
+ if not wo.x_fc_started_at:
+ wo.sudo().write({
+ 'x_fc_started_at': now,
+ 'x_fc_started_by_user_id': uid,
+ })
+ return res
def _fp_check_operator_certification(self):
"""Raise UserError if the user isn't certified for this process."""
@@ -461,14 +564,57 @@ class MrpWorkorder(models.Model):
# T1.3 — Rack MTO increment when a rack was used
# ------------------------------------------------------------------
def button_finish(self):
- """Finish the WO, bump rack MTO, spawn bake window if required."""
+ """Finish the WO, bump rack MTO, spawn bake window if required.
+
+ Also stamps the finished_by/finished_at audit fields and runs
+ the proficiency tracker so workers earn credit toward auto-
+ promotion (see fp.operator.proficiency).
+ """
res = super().button_finish()
+ now = fields.Datetime.now()
+ uid = self.env.user.id
for wo in self:
if wo.x_fc_rack_id:
wo.x_fc_rack_id._increment_mto(1.0)
+ # Audit stamp — overwrite each time the WO is closed so the
+ # most recent finish is what's shown.
+ wo.sudo().write({
+ 'x_fc_finished_at': now,
+ 'x_fc_finished_by_user_id': uid,
+ })
+ # Proficiency tracking + auto-promotion. Wrapped in try so a
+ # tracker glitch never blocks production.
+ try:
+ wo._fp_record_proficiency()
+ except Exception:
+ import logging
+ logging.getLogger(__name__).exception(
+ 'Proficiency tracker failed for WO %s', wo.id,
+ )
self._fp_spawn_bake_window_if_needed()
return res
+ def _fp_record_proficiency(self):
+ """Increment the (employee, role) completion counter and promote
+ the employee if they've crossed the role's mastery threshold.
+
+ Runs on the assigned worker, NOT the user who clicked Finish —
+ sometimes a manager finishes a job on behalf of an absent
+ operator. The CREDIT belongs to the assigned worker.
+ """
+ Prof = self.env.get('fp.operator.proficiency')
+ if Prof is None:
+ return # tracker model not installed yet — nothing to do
+ for wo in self:
+ user = wo.x_fc_assigned_user_id
+ role = wo.x_fc_work_role_id
+ if not user or not role:
+ continue
+ employee = user.employee_id
+ if not employee:
+ continue
+ Prof.sudo()._record_completion(employee, role)
+
def _fp_spawn_bake_window_if_needed(self):
"""Create a fusion.plating.bake.window record if the MO's coating
config requires it and this WO was the plating step.
diff --git a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv
index 65167f51..78c019e0 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv
@@ -17,3 +17,6 @@ access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
+access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
+access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
+access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml
index 12075356..7b726755 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml
+++ b/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml
@@ -39,12 +39,21 @@
+
+
+
+ Mastery Threshold controls auto-promotion: when an
+ operator has finished this many WOs against this role, the role is
+ added to their Shop Roles automatically and a chatter line is
+ posted to their employee record. Defaults from
+ Settings > Fusion Plating > Default Mastery Threshold.
+
- Work orders tagged with these roles will auto-assign to
- this employee (or to another employee with the same role,
- whichever is least loaded).
-
+
+
+
+ Work orders tagged with these roles auto-assign to
+ this employee (or to whoever has the same role and
+ the lighter open queue).
+
+
+
+
+
+ Lead hands appear at the top of the Manager Desk
+ worker dropdown for these roles, even when they
+ aren't the primary owner. Use for cross-trained
+ workers who can step in during absences.
+
+
+
+
+
+ Auto-tracked: every successfully completed WO bumps the
+ count for its role. When the count crosses the role's
+ mastery threshold the role is added to Tasks This
+ Operator Can Do automatically.
+
+
+
+
+
+
+
+
+
+
+
+
@@ -109,17 +156,10 @@
-
-
- mrp.workorder.form.fp.roles
- mrp.workorder
-
-
-
-
-
-
-
-
+
diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
index d7ac961c..590a711a 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
+++ b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
@@ -91,6 +91,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
index 0a3c7b4e..71b130ba 100644
--- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py
+++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
- 'version': '19.0.10.0.0',
+ 'version': '19.0.11.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
index d3243fd8..c8e70afe 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py
@@ -115,6 +115,17 @@ class FpManagerDashboardController(http.Controller):
w.x_fc_assigned_user_id.name or ''
if w.x_fc_assigned_user_id else ''
),
+ # Role required by this step. Used by the
+ # Manager Desk worker dropdown to surface
+ # qualified operators first.
+ 'role_id': (
+ w.x_fc_work_role_id.id
+ if w.x_fc_work_role_id else False
+ ),
+ 'role_name': (
+ w.x_fc_work_role_id.name or ''
+ if w.x_fc_work_role_id else ''
+ ),
}
for w in wos
],
@@ -161,11 +172,43 @@ class FpManagerDashboardController(http.Controller):
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
})
- # ---- Pickers: operators, tanks, work centres ------------------
- operators = [
- {'id': u.id, 'name': u.name}
- for u in (operator_group.user_ids if operator_group else env['res.users'])
- ]
+ # ---- Pickers: operators (with presence + role data) -----------
+ # We send richer operator records so the Manager Desk dropdown can
+ # group qualified-and-present at the top, then lead hands, then
+ # off-shift workers (greyed). Without this the manager has to
+ # remember who's clocked in and who can do what.
+ clocked_in_user_ids = (
+ env['hr.employee']._fp_clocked_in_user_ids()
+ if 'hr.employee' in env and hasattr(
+ env['hr.employee'], '_fp_clocked_in_user_ids',
+ )
+ else set()
+ )
+ operator_users = (
+ operator_group.user_ids if operator_group else env['res.users']
+ )
+ operators = []
+ for u in operator_users:
+ emp = u.employee_id
+ role_ids = emp.x_fc_work_role_ids.ids if emp else []
+ lead_role_ids = (
+ emp.x_fc_lead_hand_role_ids.ids
+ if emp and 'x_fc_lead_hand_role_ids' in emp._fields
+ else []
+ )
+ operators.append({
+ 'id': u.id,
+ 'name': u.name,
+ 'is_clocked_in': u.id in clocked_in_user_ids,
+ 'role_ids': role_ids,
+ 'lead_hand_role_ids': lead_role_ids,
+ })
+ # Headline counts so the manager sees at-a-glance who's on shift.
+ present_count = sum(1 for o in operators if o['is_clocked_in'])
+ presence = {
+ 'clocked_in': present_count,
+ 'total': len(operators),
+ }
Tank = env.get('fusion.plating.tank')
tanks = [
{
@@ -214,6 +257,7 @@ class FpManagerDashboardController(http.Controller):
'active': active_cards,
'team': team,
'operators': operators,
+ 'presence': presence,
'tanks': tanks,
'user_name': env.user.name,
}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js
index 3aa87104..0a24828d 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js
@@ -30,6 +30,13 @@ export class ManagerDashboard extends Component {
messageType: "info",
isFetching: false, // pulses the "updating" dot in the header
lastUpdated: null, // epoch ms of last successful payload
+ // Worker dropdown filter: when true, off-shift operators
+ // are HIDDEN. When false, they appear at the bottom of
+ // every dropdown (greyed) so the manager can still pick
+ // them in a pinch (training, walk-in coverage).
+ // Defaults to false because lead-hand coverage often needs
+ // off-roster names.
+ hideOffShift: false,
});
this._lastHash = null; // sent to server to skip unchanged polls
@@ -99,6 +106,8 @@ export class ManagerDashboard extends Component {
for (const k of ["unassigned", "active", "team", "operators", "tanks"]) {
if (Array.isArray(source[k])) target[k] = source[k];
}
+ // Presence dict: copy over so the badge updates on every poll.
+ if (source.presence) target.presence = source.presence;
}
/** Human-readable "updated Xs ago" label. */
@@ -125,6 +134,51 @@ export class ManagerDashboard extends Component {
this.state.expandedMoId = this.state.expandedMoId === moId ? null : moId;
}
+ toggleOffShift() {
+ this.state.hideOffShift = !this.state.hideOffShift;
+ }
+
+ /**
+ * Sort + filter the operator list for a specific WO's dropdown.
+ *
+ * Buckets, top-down, each kept in original (alphabetical) order:
+ * 1. Qualified for this role AND clocked in — primary picks
+ * 2. Lead hands for this role AND clocked in — coverage picks
+ * 3. Clocked in but NOT qualified — training mode
+ * 4. Off-shift — greyed; only
+ * shown when hideOffShift is false
+ *
+ * Each option carries a `bucket` so the template can render a tiny
+ * green/grey dot and (for buckets 3-4) a soft helper label.
+ */
+ operatorsForWO(wo) {
+ const all = (this.state.overview && this.state.overview.operators) || [];
+ const roleId = wo && wo.role_id;
+ const out = [];
+ for (const op of all) {
+ const qualified = roleId && op.role_ids && op.role_ids.includes(roleId);
+ const isLead = roleId && op.lead_hand_role_ids && op.lead_hand_role_ids.includes(roleId);
+ let bucket;
+ if (op.is_clocked_in && qualified) bucket = 1;
+ else if (op.is_clocked_in && isLead) bucket = 2;
+ else if (op.is_clocked_in) bucket = 3;
+ else bucket = 4;
+ if (this.state.hideOffShift && bucket === 4) continue;
+ out.push({ ...op, bucket, qualified, isLead });
+ }
+ // Stable sort by bucket; alphabetical name as the secondary
+ out.sort((a, b) => (a.bucket - b.bucket) || a.name.localeCompare(b.name));
+ return out;
+ }
+
+ /** Label that goes next to each option (after the name). */
+ operatorBadge(op) {
+ if (op.bucket === 1) return ""; // primary — no extra noise
+ if (op.bucket === 2) return " · lead hand";
+ if (op.bucket === 3) return " · training";
+ return " · off-shift";
+ }
+
// ---------------------------------------------------------- Actions
async onAssignWorker(wo, userIdRaw) {
const userId = parseInt(userIdRaw) || null;
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss
index c4771705..2d0db4c5 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss
@@ -79,6 +79,59 @@
50% { box-shadow: 0 0 0 8px color-mix(in srgb, #{$fp-ok} 0%, transparent); }
}
+ // ---- Presence chip (Present 7 / 12) -------------------------------------
+ // Small toggle in the header. Green dot = clocked-in workers visible
+ // in the dropdown; grey dot when filter is active (off-shift hidden).
+ // The chip itself is a button so the manager can hide off-shift names
+ // with one tap when the dropdown gets crowded during a busy shift.
+ .o_fp_presence_chip {
+ display: inline-flex;
+ align-items: center;
+ gap: $fp-space-2;
+ padding: 6px 14px;
+ border: 1px solid #{$fp-border};
+ border-radius: $fp-radius-pill;
+ background-color: $fp-card;
+ color: $fp-ink;
+ font-size: $fp-text-sm;
+ font-weight: $fp-weight-medium;
+ cursor: pointer;
+ transition: border-color $fp-dur $fp-ease,
+ background-color $fp-dur $fp-ease;
+
+ strong {
+ color: $fp-ok;
+ font-weight: $fp-weight-bold;
+ font-variant-numeric: tabular-nums;
+ }
+ @include fp-hover-only {
+ &:hover { border-color: color-mix(in srgb, #{$fp-accent} 45%, #{$fp-border}); }
+ }
+
+ // Filter active = off-shift hidden. Make the chip pop a bit so
+ // the manager remembers the filter is on.
+ &[data-active="y"] {
+ background-color: color-mix(in srgb, #{$fp-accent} 10%, transparent);
+ border-color: color-mix(in srgb, #{$fp-accent} 50%, #{$fp-border});
+ color: $fp-accent;
+ strong { color: $fp-accent; }
+ .o_fp_presence_dot { background-color: $fp-accent; }
+ }
+ }
+ .o_fp_presence_dot {
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background-color: $fp-ok;
+ flex-shrink: 0;
+ }
+
+ // ---- Worker dropdown bucket cues ----------------------------------------
+ // Browsers don't let us style each