Files
2026-04-16 20:53:53 -04:00

290 lines
11 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.
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