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