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>
187 lines
6.1 KiB
Python
187 lines
6.1 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 markupsafe import Markup
|
|
|
|
from odoo import api, fields, models
|
|
|
|
|
|
class FpQualityHold(models.Model):
|
|
"""Quality Hold — parts pulled from production for quality review.
|
|
|
|
Enables the Steelhead-style "Move Parts Into Quality Management"
|
|
workflow. An operator can split a partial quantity off a job and
|
|
place it on hold for inspection, rework, or scrap.
|
|
"""
|
|
_name = 'fusion.plating.quality.hold'
|
|
_description = 'Fusion Plating — Quality Hold'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_order = 'create_date desc'
|
|
|
|
name = fields.Char(
|
|
string='Reference',
|
|
required=True,
|
|
copy=False,
|
|
readonly=True,
|
|
default=lambda self: self._default_name(),
|
|
tracking=True,
|
|
)
|
|
|
|
# ----- What's on hold -----
|
|
# NOTE: workorder_id, production_id, and portal_job_id live in
|
|
# fusion_plating_bridge_mrp (which depends on mrp and
|
|
# fusion_plating_portal). Keeping them here would force hard
|
|
# dependencies and break minimal CE-only installs.
|
|
part_ref = fields.Char(string='Part Number')
|
|
|
|
# ----- Hold details -----
|
|
qty_on_hold = fields.Integer(string='Qty on Hold', required=True)
|
|
qty_original = fields.Integer(string='Original Qty')
|
|
mark_for_scrap = fields.Boolean(string='Mark for Scrap', default=False)
|
|
hold_reason = fields.Selection(
|
|
[
|
|
('damaged', 'Parts Damaged'),
|
|
('out_of_spec', 'Out of Specification'),
|
|
('contamination', 'Contamination'),
|
|
('customer_complaint', 'Customer Complaint'),
|
|
('process_deviation', 'Process Deviation'),
|
|
('other', 'Other'),
|
|
],
|
|
string='Hold Reason',
|
|
default='other',
|
|
tracking=True,
|
|
)
|
|
description = fields.Text(string='Description')
|
|
attachment_ids = fields.Many2many(
|
|
'ir.attachment',
|
|
string='Attachments',
|
|
)
|
|
|
|
# ----- Location / station context -----
|
|
facility_id = fields.Many2one(
|
|
'fusion.plating.facility',
|
|
string='Facility',
|
|
)
|
|
work_center_id = fields.Many2one(
|
|
'fusion.plating.work.center',
|
|
string='Station',
|
|
)
|
|
current_process_node = fields.Char(string='Current Process Node')
|
|
|
|
# ----- Status -----
|
|
state = fields.Selection(
|
|
[
|
|
('on_hold', 'On Hold'),
|
|
('under_review', 'Under Review'),
|
|
('released', 'Released to Production'),
|
|
('scrapped', 'Scrapped'),
|
|
('reworked', 'Sent to Rework'),
|
|
],
|
|
string='Status',
|
|
default='on_hold',
|
|
required=True,
|
|
tracking=True,
|
|
)
|
|
|
|
# ----- Resolution -----
|
|
ncr_id = fields.Many2one('fusion.plating.ncr', string='Linked NCR')
|
|
resolved_by_id = fields.Many2one('res.users', string='Resolved By')
|
|
resolution_date = fields.Datetime(string='Resolution Date')
|
|
resolution_notes = fields.Text(string='Resolution Notes')
|
|
|
|
# ----- Housekeeping -----
|
|
operator_id = fields.Many2one(
|
|
'res.users',
|
|
string='Held By',
|
|
default=lambda self: self.env.user,
|
|
)
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
default=lambda self: self.env.company,
|
|
)
|
|
active = fields.Boolean(default=True)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Defaults / create
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _default_name(self):
|
|
seq = self.env['ir.sequence'].next_by_code(
|
|
'fusion.plating.quality.hold',
|
|
)
|
|
return seq or '/'
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if not vals.get('name') or vals.get('name') == '/':
|
|
vals['name'] = self._default_name()
|
|
return super().create(vals_list)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Actions
|
|
# ------------------------------------------------------------------
|
|
def action_start_review(self):
|
|
self.write({'state': 'under_review'})
|
|
self._post_state_message('Under Review')
|
|
|
|
def action_release(self):
|
|
self.write({
|
|
'state': 'released',
|
|
'resolved_by_id': self.env.user.id,
|
|
'resolution_date': fields.Datetime.now(),
|
|
})
|
|
self._post_state_message('Released to Production')
|
|
|
|
def action_scrap(self):
|
|
self.write({
|
|
'state': 'scrapped',
|
|
'mark_for_scrap': True,
|
|
'resolved_by_id': self.env.user.id,
|
|
'resolution_date': fields.Datetime.now(),
|
|
})
|
|
self._post_state_message('Scrapped')
|
|
|
|
def action_send_to_rework(self):
|
|
self.write({
|
|
'state': 'reworked',
|
|
'resolved_by_id': self.env.user.id,
|
|
'resolution_date': fields.Datetime.now(),
|
|
})
|
|
self._post_state_message('Sent to Rework')
|
|
|
|
def action_create_ncr(self):
|
|
"""Create a linked NCR from this hold record."""
|
|
self.ensure_one()
|
|
ncr = self.env['fusion.plating.ncr'].create({
|
|
'facility_id': self.facility_id.id,
|
|
'source': 'shop_floor',
|
|
'severity': 'medium',
|
|
'part_ref': self.part_ref,
|
|
'quantity_affected': self.qty_on_hold,
|
|
'description': self.description or '',
|
|
})
|
|
self.write({'ncr_id': ncr.id})
|
|
self._post_state_message(f'NCR {ncr.name} created')
|
|
return {
|
|
'name': 'NCR',
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fusion.plating.ncr',
|
|
'res_id': ncr.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
def _post_state_message(self, label):
|
|
for rec in self:
|
|
rec.message_post(
|
|
body=Markup("Hold status changed to <b>%s</b>.") % label,
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|