Files
Odoo-Modules/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py
gsinghpal eed4dc8a78 fix(plating): chatter HTML rendering + workflow stage banner UX
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 `&lt;a href=...&gt;` 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>
2026-04-19 08:36:00 -04:00

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',
)