Files
Odoo-Modules/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py
gsinghpal 0d12902ee7 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>
2026-04-18 22:05:32 -04:00

120 lines
4.8 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class HrEmployee(models.Model):
"""Tag employees with the shop roles they can perform.
An employee with role 'masking' receives the masking steps when WOs
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'
x_fc_work_role_ids = fields.Many2many(
'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. '
'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)