DELETED entirely (model + view + ACL + data file + menu): - fp.coating.config (configurator) - fp.treatment (configurator + seeded data) - fp.coating.thickness (configurator) — replaced by fp.recipe.thickness in Phase A - fp.customer.price.list (configurator) — coating-keyed, no replacement Field deletions: - sale.order.x_fc_coating_config_id - sale.order.line.x_fc_coating_config_id + x_fc_treatment_ids - account.move.line.x_fc_coating_config_id - fp.part.catalog.x_fc_default_coating_config_id + x_fc_default_treatment_ids - fp.job.coating_config_id - fp.pricing.rule.coating_config_id - fp.quality.point.coating_config_ids - fp.direct.order.line.coating_config_id + treatment_ids - fp.sale.description.template.coating_config_id Refactored: - fp.quote.configurator.coating_config_id → recipe_id (now points at fusion.plating.process.node, the actual recipe). All compute, onchange, and matcher logic updated to use recipe directly. Quality inherit extends matcher with spec-tier scoring. - fp.job._fp_create_certificates now reads spec from job.customer_spec_id and formats spec_reference as "code Rev rev". Same for thickness source — bake fields read from recipe_root (Phase A). - fp.job.step.button_finish bake-window auto-spawn reads bake settings from recipe_root instead of coating. - fp.certificate auto-fill spec_min_mils/max_mils from recipe (Phase A thickness fields) instead of coating. - jobs/sale_order.py: job creation reads x_fc_customer_spec_id from line, drops coating refs and the legacy header-coating fallback. - Wizards drop coating + treatment fields and refs. - Configurator views drop x_fc_coating_config_id + x_fc_treatment_ids fields entirely. Quality inherits re-anchor on stable fields (x_fc_part_catalog_id, x_fc_internal_description, default_process_id, process_variant_id, substrate_material) so they keep working. - Reports drop coating fallback elifs; print recipe / spec. - Tablet payload drops coating_config_id from job.read fields. Skipped (deferred to backlog): - fusion_plating_bridge_mrp — module is uninstalled per Sub 11; source files retain coating refs but no runtime impact. - fusion_plating_portal — circular dep (portal → quality → certs → portal). Customer-facing portal coating picker stays for now; promote-spec polish is a separate sub-project. Verification: grep for "coating_config_id|fp.coating.config| fp.treatment|fp.coating.thickness" in live (non-bridge_mrp, non-portal, non-script, non-test) Python/XML/CSV returns 3 hits, all in module / class docstrings explaining Phase E history. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.9 KiB
Python
219 lines
7.9 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',
|
|
)
|
|
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',
|
|
},
|
|
}
|