290 lines
11 KiB
Python
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
|