# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. import logging from odoo import _, api, fields, models _logger = logging.getLogger(__name__) # Map Fusion Plating NCR states to a human readable stage name we will # try to match (case-insensitive, partial match) against any available # quality.alert.stage records. If no match is found, the alert is left # with its default stage and Odoo's own onchange will pick one. _STATE_TO_STAGE_HINT = { 'draft': 'new', 'open': 'in progress', 'containment': 'in progress', 'disposition': 'in progress', 'closed': 'done', } # Fusion Plating severity -> quality.alert priority (0..3 star scale # used across mrp/quality). Kept defensive: priority is written via # setdefault, so if a shop has customised the field we don't clobber it. _SEVERITY_TO_PRIORITY = { 'low': '0', 'medium': '1', 'high': '2', 'critical': '3', } class FpNcr(models.Model): """Extend Fusion Plating NCR so each record is mirrored into the Odoo EE ``quality.alert`` model. One-way sync, NCR is the source of truth. """ _inherit = 'fusion.plating.ncr' x_fc_quality_alert_id = fields.Many2one( 'quality.alert', string='Quality Alert', copy=False, readonly=True, help='Mirrored Odoo EE quality.alert record created from this NCR.', ) x_fc_quality_alert_synced = fields.Boolean( string='Synced to Quality', copy=False, readonly=True, help='True once this NCR has been mirrored to quality.alert at ' 'least once.', ) x_fc_auto_sync = fields.Boolean( string='Auto-sync to Quality', default=True, copy=False, help='When enabled, creating or updating this NCR automatically ' 'pushes changes to the mirrored Odoo EE quality.alert record.', ) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _fp_bridge_strip_html(self, html_value): """Convert an HTML field value to plain text for quality.alert fields that are plain Text. Kept tiny on purpose so it has no dependencies beyond what's already in core Odoo.""" if not html_value: return '' try: from odoo.tools import html2plaintext return html2plaintext(html_value) or '' except Exception: # pragma: no cover - extremely defensive return str(html_value) def _fp_bridge_team(self): """Return the dedicated Plating team record, if it exists.""" return self.env.ref( 'fusion_plating_bridge_quality.quality_team_plating', raise_if_not_found=False, ) def _fp_bridge_stage_for_state(self, state_value): """Best-effort mapping of our NCR state to a quality.alert.stage. The EE quality module ships with a handful of default stages (``New``, ``In Progress``, ``Confirmed``, ``Solved``, ``Cancelled``) but shops often rename these. We do a case-insensitive partial match against the hint and fall back to False so Odoo picks the default stage itself. """ Stage = self.env.get('quality.alert.stage') if Stage is None: return False hint = _STATE_TO_STAGE_HINT.get(state_value or '', '') if not hint: return False stage = Stage.sudo().search( [('name', 'ilike', hint)], limit=1, ) return stage.id if stage else False def _prepare_quality_alert_vals(self): """Build the vals dict used to create/update a quality.alert from this NCR. Written defensively — every field is checked against the live ``quality.alert`` model before being added, so the bridge keeps working even if a minor EE release renames or removes a field. """ self.ensure_one() Alert = self.env.get('quality.alert') if Alert is None: return {} alert_fields = Alert._fields # Build a short, dashboard-friendly title from the NCR description # (falling back to the reference). desc_plain = self._fp_bridge_strip_html(self.description) title_source = desc_plain or (self.name or '') title = (title_source or '').strip().splitlines()[0] if title_source else self.name if title and len(title) > 200: title = title[:197] + '...' if not title: title = self.name vals = {} # --- Identity / title ------------------------------------------------ if 'name' in alert_fields: # quality.alert ``name`` is usually the short title in EE. vals['name'] = title or self.name if 'title' in alert_fields: vals['title'] = title or self.name # --- Team ------------------------------------------------------------ team = self._fp_bridge_team() if team and 'team_id' in alert_fields: vals['team_id'] = team.id # --- Company --------------------------------------------------------- if 'company_id' in alert_fields and self.company_id: vals['company_id'] = self.company_id.id # --- Description ----------------------------------------------------- # quality.alert.description has historically been Html in EE; write # the raw NCR html straight through when the target is Html, and # strip to plaintext otherwise. if 'description' in alert_fields: desc_field = alert_fields['description'] if getattr(desc_field, 'type', None) == 'html': vals['description'] = self.description or False else: vals['description'] = desc_plain or False # --- Partner (customer) --------------------------------------------- if 'partner_id' in alert_fields and self.customer_partner_id: vals['partner_id'] = self.customer_partner_id.id # --- Stage / state mapping ------------------------------------------ if 'stage_id' in alert_fields: stage_id = self._fp_bridge_stage_for_state(self.state) if stage_id: vals['stage_id'] = stage_id # Some EE versions also expose a simple state selection on # quality.alert. Only touch it if it exists. if 'action_corrective' in alert_fields: # Mirror our containment narrative into corrective actions # when that html field exists on EE quality.alert. if alert_fields['action_corrective'].type == 'html': vals['action_corrective'] = self.containment or False else: vals['action_corrective'] = self._fp_bridge_strip_html( self.containment ) or False if 'action_preventive' in alert_fields: if alert_fields['action_preventive'].type == 'html': vals['action_preventive'] = self.root_cause or False else: vals['action_preventive'] = self._fp_bridge_strip_html( self.root_cause ) or False # --- Priority -------------------------------------------------------- if 'priority' in alert_fields: prio = _SEVERITY_TO_PRIORITY.get(self.severity) if prio is not None: vals['priority'] = prio # --- Reason (root cause dropdown) ----------------------------------- # quality.alert may expose ``reason_id`` pointing at quality.reason. # We do not create reason records — shops curate those themselves — # but we leave the mapping point here for future use. return vals # ------------------------------------------------------------------ # Sync engine # ------------------------------------------------------------------ def _fp_bridge_quality_available(self): """Cheap capability check: is the EE quality.alert model loaded in this database?""" return self.env.get('quality.alert') is not None def _sync_to_quality_alert(self): """Create or update the mirrored quality.alert for every NCR in ``self`` that has ``x_fc_auto_sync`` enabled. Silent no-op when the EE quality module isn't available.""" if not self._fp_bridge_quality_available(): return Alert = self.env['quality.alert'].sudo() for ncr in self: if not ncr.x_fc_auto_sync: continue try: vals = ncr._prepare_quality_alert_vals() if not vals: continue if ncr.x_fc_quality_alert_id: ncr.x_fc_quality_alert_id.sudo().write(vals) else: alert = Alert.create(vals) # Bypass normal write to avoid re-triggering sync. ncr.with_context( fp_bridge_skip_sync=True, ).write({ 'x_fc_quality_alert_id': alert.id, 'x_fc_quality_alert_synced': True, }) except Exception as exc: _logger.warning( 'Fusion Plating bridge: failed to sync NCR %s to ' 'quality.alert: %s', ncr.name, exc, ) # Non-fatal — never break the NCR save just because the # mirror failed. def action_sync_to_quality(self): """Manual "Sync to Quality" header button.""" self._sync_to_quality_alert() return {'type': 'ir.actions.client', 'tag': 'reload'} def action_view_quality_alert(self): self.ensure_one() if not self.x_fc_quality_alert_id: return False return { 'name': _('Quality Alert'), 'type': 'ir.actions.act_window', 'res_model': 'quality.alert', 'res_id': self.x_fc_quality_alert_id.id, 'view_mode': 'form', 'target': 'current', } # ------------------------------------------------------------------ # CRUD overrides # ------------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) if not self.env.context.get('fp_bridge_skip_sync'): records._sync_to_quality_alert() return records # Fields whose changes should trigger a resync. _FP_BRIDGE_SYNC_FIELDS = { 'description', 'root_cause', 'containment', 'disposition', 'state', 'severity', 'customer_partner_id', 'x_fc_auto_sync', } def write(self, vals): result = super().write(vals) if self.env.context.get('fp_bridge_skip_sync'): return result if self._FP_BRIDGE_SYNC_FIELDS & set(vals.keys()): self._sync_to_quality_alert() return result