feat(plating): in-Odoo notifications, timer audit, presence-aware Manager Desk, auto-promotion

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>
This commit is contained in:
gsinghpal
2026-04-18 22:05:32 -04:00
parent c1d26f3168
commit 0d12902ee7
18 changed files with 744 additions and 42 deletions

View File

@@ -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': """

View File

@@ -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',

View File

@@ -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',
)

View File

@@ -27,6 +27,16 @@
<field name="x_fc_default_tz"/>
</setting>
</block>
<block title="Workforce Settings"
name="fp_workforce_settings"
help="Defaults that govern how the shop tracks worker skills and promotions across recipes.">
<setting id="fp_default_mastery"
string="Default Mastery Threshold"
help="How many successful WO completions an operator needs on a new task before it's added to their Shop Roles automatically. Each role can override this on its own form (e.g. masking 1, electroless nickel 5).">
<field name="x_fc_default_mastery_threshold"/>
</setting>
</block>
</app>
</xpath>
</field>

View File

@@ -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',

View File

@@ -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

View File

@@ -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=_(
'🎉 <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',
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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 <b>%s</b>.', 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 = '<br/>'.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.

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_fp_job_consumption_manager fp.job.consumption.manager model_fp_job_consumption fusion_plating.group_fusion_plating_manager 1 1 1 1
18 access_fp_work_role_operator fp.work.role.operator model_fp_work_role fusion_plating.group_fusion_plating_operator 1 0 0 0
19 access_fp_work_role_manager fp.work.role.manager model_fp_work_role fusion_plating.group_fusion_plating_manager 1 1 1 1
20 access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency fusion_plating.group_fusion_plating_operator 1 0 0 0
21 access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency fusion_plating.group_fusion_plating_supervisor 1 1 1 0
22 access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -39,12 +39,21 @@
</group>
<group>
<field name="active" widget="boolean_toggle"/>
<field name="mastery_required"/>
</group>
</group>
<group>
<field name="description"
placeholder="Short operator-facing description of what this role covers."/>
</group>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-1"/>
<strong>Mastery Threshold</strong> 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
<em>Settings &gt; Fusion Plating &gt; Default Mastery Threshold</em>.
</div>
</sheet>
</form>
</field>
@@ -73,24 +82,62 @@
sequence="55"
groups="fusion_plating.group_fusion_plating_manager"/>
<!-- Employee form — add roles section -->
<!-- Employee form — Shop Roles + Lead Hand For + Proficiency tracker -->
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
<field name="name">hr.employee.form.fp.roles</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Shop Roles" name="fp_shop_roles">
<page string="Shop Roles" name="fp_shop_roles"
groups="fusion_plating.group_fusion_plating_supervisor">
<group>
<field name="x_fc_work_role_ids" widget="many2many_tags"
options="{'no_create_edit': True}"
placeholder="Tag the shop roles this employee performs..."/>
<div class="text-muted" colspan="2">
Work orders tagged with these roles will auto-assign to
this employee (or to another employee with the same role,
whichever is least loaded).
</div>
<group string="Tasks This Operator Can Do">
<field name="x_fc_work_role_ids"
widget="many2many_tags"
options="{'no_create_edit': True, 'color_field': 'color'}"
placeholder="Tag the shop roles this employee performs..."/>
<div class="text-muted small" colspan="2">
Work orders tagged with these roles auto-assign to
this employee (or to whoever has the same role and
the lighter open queue).
</div>
</group>
<group string="Lead Hand For"
groups="fusion_plating.group_fusion_plating_manager">
<field name="x_fc_lead_hand_role_ids"
widget="many2many_tags"
options="{'no_create_edit': True}"
placeholder="Roles where this employee can cover for absent operators..."/>
<div class="text-muted small" colspan="2">
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.
</div>
</group>
</group>
<separator string="Task Proficiency"/>
<p class="text-muted small">
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 <em>Tasks This
Operator Can Do</em> automatically.
</p>
<field name="x_fc_proficiency_ids" nolabel="1"
readonly="1">
<list>
<field name="role_id"/>
<field name="completed_count"/>
<field name="progress_label" string="Progress"/>
<field name="promoted" widget="boolean_toggle"
readonly="1"/>
<field name="first_completed_at"/>
<field name="last_completed_at"/>
<field name="promoted_at"/>
</list>
</field>
</page>
</xpath>
</field>
@@ -109,17 +156,10 @@
</field>
</record>
<!-- Work Order form — show role + assigned worker -->
<record id="view_mrp_workorder_form_fp_roles" model="ir.ui.view">
<field name="name">mrp.workorder.form.fp.roles</field>
<field name="model">mrp.workorder</field>
<field name="inherit_id" ref="fusion_plating_bridge_mrp.view_mrp_workorder_form_fp_bridge"/>
<field name="arch" type="xml">
<xpath expr="//sheet//field[@name='x_fc_customer_id']" position="after">
<field name="x_fc_work_role_id" readonly="1"/>
<field name="x_fc_assigned_user_id"/>
</xpath>
</field>
</record>
<!--
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
via mrp_workorder_views.xml (after production_id). The earlier inherit
here would cause the fields to render twice.
-->
</odoo>

View File

@@ -91,6 +91,10 @@
<xpath expr="//sheet//field[@name='production_id']" position="after">
<field name="x_fc_step_display" widget="badge" readonly="1"/>
<field name="x_fc_priority" widget="priority"/>
<field name="x_fc_assigned_user_id"
string="Assigned To"
options="{'no_create': True}"/>
<field name="x_fc_work_role_id" readonly="1"/>
</xpath>
<!-- ============================================================
@@ -136,6 +140,24 @@
string="Expected Duration" readonly="1"/>
</group>
</group>
<!--
Audit trail surfaced from the timer overrides.
Mirrors what's already in time_ids (one row per
pause/resume) but distilled to the two events
that matter to the manager: who first picked the
job up, and who closed it out.
-->
<group string="Timer Audit" name="timer_audit">
<group>
<field name="x_fc_started_by_user_id" readonly="1"/>
<field name="x_fc_started_at" readonly="1"/>
</group>
<group>
<field name="x_fc_finished_by_user_id" readonly="1"/>
<field name="x_fc_finished_at" readonly="1"/>
</group>
</group>
</xpath>
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->

View File

@@ -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.',

View File

@@ -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,
}

View File

@@ -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;

View File

@@ -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 <option> very richly, but we can
// colour the text of off-shift / training options to give the manager
// a glanceable hint about who the "good" picks are.
.o_fp_mgr_picker option[data-bucket="3"] { color: $fp-ink-mute; }
.o_fp_mgr_picker option[data-bucket="4"] { color: $fp-ink-faint; font-style: italic; }
.o_fp_manager_head_actions {
display: flex; gap: $fp-space-2;

View File

@@ -24,6 +24,21 @@
</div>
</div>
<div class="o_fp_manager_head_actions">
<!-- Presence chip — clocked-in workers vs roster.
Tap to toggle whether off-shift names show in
the worker dropdowns. -->
<button class="btn o_fp_presence_chip"
t-att-data-active="state.hideOffShift ? 'y' : 'n'"
t-on-click="toggleOffShift"
t-att-title="state.hideOffShift ? 'Showing only clocked-in workers — click to include off-shift' : 'Showing all workers — click to hide off-shift'"
t-if="state.overview and state.overview.presence">
<span class="o_fp_presence_dot"/>
Present
<strong>
<t t-esc="state.overview.presence.clocked_in"/>
</strong>
/ <t t-esc="state.overview.presence.total"/>
</button>
<button class="btn"
t-on-click="refresh"
t-att-disabled="state.isFetching">
@@ -129,10 +144,13 @@
<select class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignWorker(wo, ev.target.value)">
<option value="">— Assign worker —</option>
<t t-foreach="state.overview.operators" t-as="op" t-key="op.id">
<t t-foreach="operatorsForWO(wo)" t-as="op" t-key="op.id">
<option t-att-value="op.id"
t-att-selected="wo.assigned_user_id === op.id">
<t t-esc="op.name"/>
t-att-selected="wo.assigned_user_id === op.id"
t-att-data-bucket="op.bucket">
<t t-if="op.is_clocked_in"></t>
<t t-else=""></t>
<t t-esc="' ' + op.name + operatorBadge(op)"/>
</option>
</t>
</select>