Files
Odoo-Modules/fusion_plating/fusion_plating_quality/models/fp_quality_point.py
gsinghpal c637f82ae2 feat(promote-customer-spec): Phase C — pricing, quality, job, cert re-keyed
Pricing:
- Quality inherit on fp.pricing.rule adds customer_spec_id + recipe_id
- Quality inherit on fp.quote.configurator adds customer_spec_id field
  + extends _find_matching_rule with priority chain:
    spec (+8) > recipe (+6) > coating (+4) > material (+2) > cert (+1)
- View inherit surfaces both new pickers on the rule form

Quality points:
- fp.quality.point now has customer_spec_ids + recipe_ids M2M filters
- Matcher (_matches + _find_matching) accepts new args
- Hook overrides on SO confirm + job confirm/done + step finish
  pass spec/recipe context through to the matcher
- View surfaces both new M2M widgets

Job:
- jobs/sale_order.py wires x_fc_customer_spec_id from SO line to
  fp.job.customer_spec_id on action_confirm

Cert:
- Quality inherit on fp.certificate adds customer_spec_id field +
  create() override auto-fills spec_reference from spec.code+revision
  Resolution priority: explicit spec_reference > cert.customer_spec_id
  > SO line spec (with print_on_cert) > legacy coating fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:23:06 -04:00

226 lines
8.2 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.
#
# Sub 12 Phase C — trigger-based quality points.
#
# Replaces the old "customer.x_fc_requires_qc + customer.x_fc_qc_template_id"
# direct binding. Now an admin defines fp.quality.point rules with filters
# (partner / part / coating / step kind) and a trigger event; matching
# records spawn fusion.plating.quality.check rows from the chosen template.
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
TRIGGER_TYPES = [
('manual', 'Manual'),
('so_confirmed', 'Sale Order Confirmed'),
('receiving_done', 'Receiving Closed'),
('job_confirmed', 'Job Confirmed'),
('job_step_done', 'Job Step Finished'),
('job_done', 'Job Completed'),
]
STEP_KINDS = [
('wet', 'Wet Process'),
('bake', 'Bake / Cure'),
('inspect', 'Inspection'),
('mask', 'Masking'),
('post', 'Post-Treatment'),
('other', 'Other'),
]
class FpQualityPoint(models.Model):
_name = 'fp.quality.point'
_description = 'Fusion Plating — Quality Point'
_inherit = ['mail.thread']
_order = 'sequence, name'
name = fields.Char(required=True, translate=True, tracking=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True, tracking=True)
description = fields.Text(translate=True)
trigger_type = fields.Selection(
TRIGGER_TYPES, string='Trigger', required=True,
default='job_confirmed', tracking=True,
help='When this point fires. "manual" never auto-fires.',
)
# ----- Filters (all optional; empty == match all) -----
partner_ids = fields.Many2many(
'res.partner', 'fp_quality_point_partner_rel',
'point_id', 'partner_id', string='Customers',
)
part_catalog_ids = fields.Many2many(
'fp.part.catalog', 'fp_quality_point_part_rel',
'point_id', 'part_id', string='Parts',
)
coating_config_ids = fields.Many2many(
'fp.coating.config', 'fp_quality_point_coating_rel',
'point_id', 'coating_id', string='Coatings',
)
customer_spec_ids = fields.Many2many(
'fusion.plating.customer.spec',
'fp_quality_point_spec_rel',
'point_id', 'spec_id',
string='Specifications',
help='If set, this trigger only fires for SOs / jobs whose '
'specification is in this list. Leave blank to ignore spec.',
)
recipe_ids = fields.Many2many(
'fusion.plating.process.node',
'fp_quality_point_recipe_rel',
'point_id', 'recipe_id',
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
string='Recipes',
help='If set, this trigger only fires for jobs running one of '
'these recipes. Leave blank to ignore recipe.',
)
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Checklist Template',
required=True, ondelete='restrict',
)
assignee_user_id = fields.Many2one(
'res.users', string='Default Inspector',
help='If set, the auto-spawned QC check is pre-assigned here.',
)
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_quality_point_tag_rel',
'point_id', 'tag_id', string='Tags',
)
# Stats
spawn_count = fields.Integer(
string='Checks Spawned', compute='_compute_spawn_count',
)
@api.depends('template_id')
def _compute_spawn_count(self):
Check = self.env['fusion.plating.quality.check']
for rec in self:
if not rec.template_id:
rec.spawn_count = 0
continue
rec.spawn_count = Check.search_count([
('template_id', '=', rec.template_id.id),
])
# ------------------------------------------------------------------
# Matching + spawning
# ------------------------------------------------------------------
def _matches(self, partner=None, part=None, coating=None, step=None,
customer_spec=None, recipe=None):
"""Return True if this point's filters all pass against the supplied
context. Empty filter == match anything.
"""
self.ensure_one()
if self.partner_ids and (not partner or partner not in self.partner_ids):
return False
if self.part_catalog_ids and (
not part or part not in self.part_catalog_ids):
return False
if self.coating_config_ids and (
not coating or coating not in self.coating_config_ids):
return False
if self.customer_spec_ids and (
not customer_spec
or customer_spec not in self.customer_spec_ids):
return False
if self.recipe_ids and (
not recipe or recipe not in self.recipe_ids):
return False
if self.step_kind and step and getattr(step, 'kind', None) \
and step.kind != self.step_kind:
return False
return True
@api.model
def _find_matching(self, trigger, partner=None, part=None, coating=None,
step=None, customer_spec=None, recipe=None):
"""Return active points whose trigger + filters match the context."""
candidates = self.search([
('active', '=', True),
('trigger_type', '=', trigger),
])
return candidates.filtered(lambda p: p._matches(
partner=partner, part=part, coating=coating, step=step,
customer_spec=customer_spec, recipe=recipe,
))
def _spawn_check_for(self, source, partner=None, job=None, step=None):
"""Create a fusion.plating.quality.check from this point's template.
Idempotent per (point, source): if a check already exists with the
same template_id and the same job/step binding, no new one is
created (returns the existing one).
"""
self.ensure_one()
Check = self.env['fusion.plating.quality.check']
if not self.template_id:
_logger.warning(
'fp.quality.point %s: no template_id set, skipping spawn.',
self.name,
)
return False
domain = [('template_id', '=', self.template_id.id)]
if job:
domain.append(('job_id', '=', job.id))
if step and 'step_id' in Check._fields:
domain.append(('step_id', '=', step.id))
existing = Check.search(domain, limit=1)
if existing:
return existing
vals = {
'template_id': self.template_id.id,
}
# Best-effort field bindings — survives schema variations.
if 'partner_id' in Check._fields and partner:
vals['partner_id'] = partner.id
if 'job_id' in Check._fields and job:
vals['job_id'] = job.id
if 'step_id' in Check._fields and step:
vals['step_id'] = step.id
if 'state' in Check._fields:
vals['state'] = 'pending'
if 'inspector_id' in Check._fields and self.assignee_user_id:
vals['inspector_id'] = self.assignee_user_id.id
if 'team_id' in Check._fields and self.team_id:
vals['team_id'] = self.team_id.id
if 'tag_ids' in Check._fields and self.tag_ids:
vals['tag_ids'] = [(6, 0, self.tag_ids.ids)]
try:
return Check.create(vals)
except Exception as e:
_logger.warning(
'fp.quality.point %s: spawn failed for %s%s',
self.name, source.display_name if source else '?', e,
)
return False
def action_spawn_manual(self):
"""Manual fire — present from the form view button. No source ctx."""
for rec in self:
rec._spawn_check_for(source=rec)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Quality Point fired'),
'message': _('Spawned %s check(s).') % len(self),
'type': 'success',
},
}