folder rename
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_ncr
|
||||
289
fusion_plating/fusion_plating_bridge_quality/models/fp_ncr.py
Normal file
289
fusion_plating/fusion_plating_bridge_quality/models/fp_ncr.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user