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 @@ + + @@ -73,24 +82,62 @@ sequence="55" groups="fusion_plating.group_fusion_plating_manager"/> - + hr.employee.form.fp.roles hr.employee - + - -
- 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