# -*- 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', ) 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, 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.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, 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, 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', }, }