Two fixes from a single SO walkthrough screenshot:
**1. "Current stage" banner**
- Was placed `inside sheet` so it rendered at the BOTTOM of the form
where users miss it. Moved to `before form/header` (same xpath
pattern as the Account Hold banner) — now it's the first thing
visible above the SO header.
- Was still showing "Shipped — awaiting invoice" after the invoice
was posted because `_compute_workflow_stage` only advanced to
`complete` when shipped + ALL paid; an unpaid posted invoice left
the SO stuck on `shipped`. Added an `invoicing` branch: shipped +
has_posted_invoice → invoicing. Banner invisible-list now also
includes `invoicing` and `paid`, so the banner only shows for
in-progress steps.
**2. Chatter messages rendering raw HTML tags as text**
Odoo 19 escapes any string passed to `message_post(body=...)`
unless wrapped in `markupsafe.Markup`. We had ~10 places posting
HTML (`<a href>`, `<b>`, `<br/>`, `<code>`, `<pre>`) that all
showed up as `<a href=...>` literal text in the chatter.
Wrapped each one with `Markup(_(...))` so the tags render. Files
touched:
- fusion_plating_bridge_mrp/models/sale_order.py
(auto-MO failure code block, "Draft MO created" link,
"Job assigned to <b>" message)
- fusion_plating_bridge_mrp/models/mrp_production.py
("Recipe steps" pre/br block on each WO)
- fusion_plating_bridge_mrp/models/fp_proficiency.py
(operator promotion announcement)
- fusion_plating_configurator/models/fp_quote_configurator.py
(SO link, 3D model attached, drawing attached, save to catalog)
- fusion_plating_configurator/models/fp_part_catalog.py
(3D/drawing change tracking + propagation to linked quotes)
- fusion_plating_portal/models/fp_quote_request.py
(RFQ → SO link)
- fusion_plating_quality/models/fp_quality_hold.py
(hold status change)
- fusion_plating_shopfloor/controllers/manager_controller.py
(worker / tank / manager-takeover assignments)
Verified on entech: SO S00038 stage now reads `invoicing` (banner
hidden), and a freshly posted message shows `<a href>` and `<b>`
as actual link + bold instead of escaped text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
6.3 KiB
Python
176 lines
6.3 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.
|
|
"""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 markupsafe import Markup
|
|
|
|
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=Markup(_(
|
|
'🎉 <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',
|
|
)
|