feat(promote-customer-spec): Phase E — final removal of coating + treatment

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>
This commit is contained in:
gsinghpal
2026-05-15 02:00:41 -04:00
parent e0eacc2530
commit d891002c84
54 changed files with 233 additions and 1283 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.5.6.0', 'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """ 'description': """

View File

@@ -286,14 +286,27 @@ class FpCertificate(models.Model):
def create(self, vals_list): def create(self, vals_list):
SaleOrder = self.env['sale.order'] SaleOrder = self.env['sale.order']
for vals in vals_list: for vals in vals_list:
# Spec-limit auto-fill (existing behaviour, preserved). # Spec-limit auto-fill — sources thickness range from the
# recipe (Phase A moved the thickness fields onto the
# recipe root). Falls back gracefully when the SO has no
# recipe-bearing line.
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils') already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
if not already_set and vals.get('sale_order_id'): if not already_set and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id']) so = SaleOrder.browse(vals['sale_order_id'])
cfg = getattr(so, 'x_fc_coating_config_id', False) # Look across order_line for the first recipe with a
if cfg and cfg.thickness_uom == 'mils': # populated thickness range.
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0) first_line = so.order_line[:1] if so.order_line else False
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0) recipe = (
first_line.x_fc_process_variant_id
if (first_line
and 'x_fc_process_variant_id' in first_line._fields)
else False
)
if (recipe
and 'thickness_uom' in recipe._fields
and recipe.thickness_uom == 'mils'):
vals.setdefault('spec_min_mils', recipe.thickness_min or 0.0)
vals.setdefault('spec_max_mils', recipe.thickness_max or 0.0)
# Defer naming: let the record exist so the mixin can write # Defer naming: let the record exist so the mixin can write
# name via raw SQL, then fall back to the legacy sequence if # name via raw SQL, then fall back to the legacy sequence if
# no parent SO is reachable. # no parent SO is reachable.

View File

@@ -21,8 +21,6 @@ def _backfill_currency(env):
return return
for model_name in ( for model_name in (
'fp.pricing.rule', 'fp.pricing.rule',
'fp.treatment',
'fp.customer.price.list',
'fp.quote.configurator', 'fp.quote.configurator',
): ):
Model = env.get(model_name) Model = env.get(model_name)

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Configurator', 'name': 'Fusion Plating — Configurator',
'version': '19.0.19.0.0', 'version': '19.0.20.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """ 'description': """
@@ -39,16 +39,11 @@ Provides:
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml', 'data/fp_configurator_sequence_data.xml',
'data/fp_sub5_sequence_data.xml', 'data/fp_sub5_sequence_data.xml',
'data/fp_treatment_data.xml',
'data/fp_part_material_data.xml', 'data/fp_part_material_data.xml',
'views/fp_treatment_views.xml',
'views/fp_part_material_views.xml', 'views/fp_part_material_views.xml',
'views/fp_coating_thickness_views.xml',
'views/fp_part_catalog_views.xml', 'views/fp_part_catalog_views.xml',
'views/fp_process_node_part_scoped_views.xml', 'views/fp_process_node_part_scoped_views.xml',
'views/fp_coating_config_views.xml',
'views/fp_pricing_rule_views.xml', 'views/fp_pricing_rule_views.xml',
'views/fp_customer_price_list_views.xml',
'views/fp_quote_configurator_views.xml', 'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',

View File

@@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo noupdate="1">
<!-- Pre-treatments -->
<record id="treatment_alkaline_clean" model="fp.treatment">
<field name="name">Alkaline Clean</field>
<field name="treatment_type">pre</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">15</field>
</record>
<record id="treatment_acid_etch" model="fp.treatment">
<field name="name">Acid Etch</field>
<field name="treatment_type">pre</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">10</field>
</record>
<record id="treatment_zincate" model="fp.treatment">
<field name="name">Zincate (Aluminium)</field>
<field name="treatment_type">pre</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">5</field>
</record>
<record id="treatment_bead_blast" model="fp.treatment">
<field name="name">Bead Blast</field>
<field name="treatment_type">pre</field>
<field name="sequence">40</field>
<field name="default_duration_minutes">20</field>
</record>
<record id="treatment_degrease" model="fp.treatment">
<field name="name">Solvent Degrease</field>
<field name="treatment_type">pre</field>
<field name="sequence">50</field>
<field name="default_duration_minutes">10</field>
</record>
<!-- Post-treatments -->
<record id="treatment_bake" model="fp.treatment">
<field name="name">Hydrogen Embrittlement Bake</field>
<field name="treatment_type">post</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">240</field>
</record>
<record id="treatment_passivate" model="fp.treatment">
<field name="name">Passivate</field>
<field name="treatment_type">post</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">30</field>
</record>
<record id="treatment_chromate_seal" model="fp.treatment">
<field name="name">Chromate Seal</field>
<field name="treatment_type">post</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">15</field>
</record>
</odoo>

View File

@@ -3,14 +3,10 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from . import fp_treatment
from . import fp_part_material from . import fp_part_material
from . import fp_part_catalog from . import fp_part_catalog
from . import fp_coating_thickness
from . import fp_coating_config
from . import fp_pricing_complexity_surcharge from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule from . import fp_pricing_rule
from . import fp_customer_price_list
from . import fp_sale_description_template from . import fp_sale_description_template
from . import fp_quote_configurator from . import fp_quote_configurator
from . import fp_serial from . import fp_serial

View File

@@ -70,8 +70,7 @@ class AccountMoveLine(models.Model):
string='Thickness', string='Thickness',
help='Copied from sale.order.line for customer-facing invoice PDFs.', help='Copied from sale.order.line for customer-facing invoice PDFs.',
) )
# x_fc_customer_spec_id is added by fusion_plating_quality (where # x_fc_customer_spec_id added by fusion_plating_quality.
# fusion.plating.customer.spec lives).
x_fc_revision_snapshot = fields.Char( x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)', string='Revision (snapshot)',
help='Revision letter from the source SO line.', help='Revision letter from the source SO line.',

View File

@@ -1,91 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpCoatingConfig(models.Model):
"""Coating configuration template.
Defines a specific coating setup: process type, phosphorus level,
thickness range, spec reference, and required pre/post treatments.
Used by the configurator to drive pricing and recipe selection.
"""
_name = 'fp.coating.config'
_description = 'Fusion Plating — Coating Configuration'
_order = 'sequence, name'
name = fields.Char(string='Configuration', required=True, help='e.g. "EN Mid-Phos AMS 2404"')
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type', required=True, ondelete='restrict',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node', string='Default Recipe',
domain="[('node_type', '=', 'recipe')]",
help='Default recipe template for this coating configuration.',
)
phosphorus_level = fields.Selection(
[('low_phos', 'Low Phosphorus (2-5%)'), ('mid_phos', 'Mid Phosphorus (6-9%)'),
('high_phos', 'High Phosphorus (10-13%)'), ('na', 'N/A')],
string='Phosphorus Level', default='na', help='EN-specific. Set to N/A for non-EN processes.',
)
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
string='Thickness UoM', default='mils',
)
thickness_option_ids = fields.One2many(
'fp.coating.thickness',
'coating_config_id',
string='Thickness Options',
help='Discrete thickness values the estimator can pick from when '
'this coating appears on a sale order line. Each value is '
'driven by the spec the coating is built against. Sub 5.',
)
spec_reference = fields.Char(string='Spec Reference', help='e.g. "AMS 2404", "E499-303-00-005"')
certification_level = fields.Selection(
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
string='Certification Level', default='commercial',
)
pre_treatment_ids = fields.Many2many(
'fp.treatment', 'fp_coating_config_pre_treatment_rel', 'config_id', 'treatment_id',
string='Pre-Treatments', domain="[('treatment_type', '=', 'pre')]",
)
post_treatment_ids = fields.Many2many(
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
)
# ---- Hydrogen embrittlement relief (AMS 2759/9) ----
requires_bake_relief = fields.Boolean(
string='Requires Bake Relief',
help='Hydrogen embrittlement relief bake required (high-strength steel, '
'Rockwell C ≥ 31). When set, finishing the plating WO auto-creates '
'a bake window record and blocks shipment until bake is complete.',
)
bake_window_hours = fields.Float(
string='Bake Window (hours)', default=4.0,
help='Maximum time between plate exit and bake start. Typically 4h per AMS 2759/9.',
)
bake_temperature = fields.Float(
string='Bake Temperature', default=375.0,
help='Relief bake temperature. Default 375 (°F per AMS 2759/9 for '
'steel ≥ HRC 40). Unit follows bake_temperature_uom.',
)
bake_temperature_uom = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Temp Unit',
default=lambda self: self.env.company.x_fc_default_temp_uom or 'F',
)
bake_duration_hours = fields.Float(
string='Bake Duration (hours)', default=23.0,
help='Minimum bake hold time at temperature. Typical: 23h.',
)
sequence = fields.Integer(string='Sequence', default=10)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)

View File

@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
class FpCoatingThickness(models.Model):
"""Allowed thickness option for a coating configuration.
Each plating process (ENP Class 4, hard chrome 0.001", Type III
anodize, etc.) has its own set of valid thicknesses driven by the
spec it's built from. This child of `fp.coating.config` holds the
discrete options so the SO-line thickness dropdown can filter to
only what's actually achievable for the line's coating.
"""
_name = 'fp.coating.thickness'
_description = 'Coating Thickness Option'
_order = 'coating_config_id, sequence, value'
coating_config_id = fields.Many2one(
'fp.coating.config',
required=True,
ondelete='cascade',
)
value = fields.Float(
string='Nominal',
digits=(10, 4),
required=True,
help='Target / nominal thickness value (the number printed on the cert). '
'Magnitude only — UoM lives in the next field.',
)
# Hitting an exact thickness on plated parts is impossible — the spec
# is always "X mils ± tolerance" or a min/max range. These fields
# capture the acceptance band so QC can mark a reading pass/fail
# against real customer specs (e.g. AMS-2404 Class 4 = 0.001"0.0015").
# Both optional: leave blank for legacy single-value entries.
value_min = fields.Float(
string='Min',
digits=(10, 4),
help='Lower acceptance bound. Readings below this fail QC.',
)
value_max = fields.Float(
string='Max',
digits=(10, 4),
help='Upper acceptance bound. Readings above this fail QC.',
)
uom = fields.Selection(
[('mils', 'mils (0.001 in)'),
('microns', 'microns (µm)'),
('inches', 'inches'),
('mm', 'mm')],
required=True,
default='mils',
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('value', 'value_min', 'value_max', 'uom')
def _compute_display_name(self):
uom_labels = dict(self._fields['uom'].selection)
for rec in self:
label = uom_labels.get(rec.uom, rec.uom or '')
# Strip the bracketed clarification for a tighter dropdown row.
if ' (' in label:
label = label.split(' (')[0]
# Range overrides single value when both bounds are set —
# operators see the real spec, not a phantom-precise nominal.
if rec.value_min and rec.value_max:
rec.display_name = (
f'{rec.value_min:g}{rec.value_max:g} {label}'.strip()
)
elif rec.value:
rec.display_name = f'{rec.value:g} {label}'.strip()
else:
rec.display_name = label
@api.constrains('value_min', 'value_max')
def _check_range(self):
for rec in self:
if rec.value_min and rec.value_max and rec.value_min > rec.value_max:
from odoo.exceptions import ValidationError
raise ValidationError(_(
'Thickness Min (%(mn)s) cannot exceed Max (%(mx)s).'
) % {'mn': rec.value_min, 'mx': rec.value_max})

View File

@@ -1,97 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpCustomerPriceList(models.Model):
"""Standing price per (customer, coating config).
Repeat customers accept a negotiated price per coating — the configurator
and Direct Order wizard auto-fill `unit_price` from here before falling
back to the formula-based pricing engine.
Optional effective_from / effective_to support annual contracts.
"""
_name = 'fp.customer.price.list'
_description = 'Fusion Plating — Customer Price List'
_inherit = ['mail.thread']
_order = 'partner_id, coating_config_id, effective_from desc'
name = fields.Char(
string='Reference', compute='_compute_name', store=True,
)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, ondelete='cascade',
tracking=True, domain="[('customer_rank', '>', 0)]",
)
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating', required=True, ondelete='restrict',
tracking=True,
)
unit_price = fields.Monetary(
string='Unit Price', required=True, currency_field='currency_id',
tracking=True,
)
price_uom = fields.Selection(
[('per_part', 'per Part'),
('per_sqin', 'per sq in'),
('per_sqft', 'per sq ft'),
('per_lb', 'per lb')],
string='Price Basis', default='per_part', required=True,
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
required=True, default=lambda self: self.env.company.currency_id,
)
effective_from = fields.Date(
string='Effective From', default=fields.Date.today, required=True, tracking=True,
)
effective_to = fields.Date(
string='Effective To',
help='Blank = no expiry. Set for annual contract pricing.',
tracking=True,
)
min_quantity = fields.Integer(
string='Minimum Qty', default=1,
help='Volume break — this price applies for orders of this size or larger.',
)
notes = fields.Html(string='Notes')
active = fields.Boolean(default=True)
_sql_constraints = [
('fp_price_list_unique',
'unique(partner_id, coating_config_id, effective_from, min_quantity)',
'A price entry already exists for this customer + coating + '
'effective date + quantity tier.'),
]
@api.depends('partner_id', 'coating_config_id', 'min_quantity', 'effective_from')
def _compute_name(self):
for rec in self:
parts = []
if rec.partner_id:
parts.append(rec.partner_id.name)
if rec.coating_config_id:
parts.append(rec.coating_config_id.name)
if rec.min_quantity > 1:
parts.append(f'{rec.min_quantity}')
rec.name = ' / '.join(parts) if parts else ''
@api.model
def _find_price(self, partner_id, coating_config_id, quantity=1, on_date=None):
"""Return the best-matching active price list entry for this request."""
if not (partner_id and coating_config_id):
return False
on_date = on_date or fields.Date.today()
candidates = self.search([
('partner_id', '=', partner_id),
('coating_config_id', '=', coating_config_id),
('active', '=', True),
('effective_from', '<=', on_date),
'|', ('effective_to', '=', False), ('effective_to', '>=', on_date),
('min_quantity', '<=', quantity),
], order='min_quantity desc, effective_from desc')
return candidates[:1]

View File

@@ -277,21 +277,8 @@ class FpPartCatalog(models.Model):
rec.process_variant_count = len(variants) rec.process_variant_count = len(variants)
# ---- Direct-order defaults (Phase C — C4) ---- # ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one( # x_fc_default_customer_spec_id added by fusion_plating_quality.
'fp.coating.config', # Legacy default_coating_config_id + default_treatment_ids removed.
string='Default Treatment',
help='Default coating applied when this part is dropped onto a '
'direct order line. Updated when "Save as Default" is ticked.',
)
# x_fc_default_customer_spec_id is added by fusion_plating_quality
# (where fusion.plating.customer.spec lives).
x_fc_default_treatment_ids = fields.Many2many(
'fp.treatment',
relation='fp_part_catalog_default_treatment_rel',
string='Default Additional Treatments',
help='Default additional treatments. Seeded when "Save as Default" '
'is ticked on a direct order line.',
)
# Substrate density mapping (g/cm³) for material weight calculation # Substrate density mapping (g/cm³) for material weight calculation
_SUBSTRATE_DENSITY = { _SUBSTRATE_DENSITY = {

View File

@@ -18,8 +18,9 @@ class FpPricingRule(models.Model):
_order = 'sequence, id' _order = 'sequence, id'
name = fields.Char(string='Rule Name', required=True) name = fields.Char(string='Rule Name', required=True)
coating_config_id = fields.Many2one('fp.coating.config', string='Coating Config', # coating_config_id removed. Spec + recipe match keys live on
help='Leave blank for a global rule.') # fusion_plating_quality.fp_pricing_rule_inherit. Material +
# cert_level (below) remain as generic filters.
substrate_material = fields.Selection( substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'), [('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')], ('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],

View File

@@ -243,8 +243,15 @@ class FpQuoteConfigurator(models.Model):
upload_po_file = fields.Binary(string='Upload PO', attachment=False) upload_po_file = fields.Binary(string='Upload PO', attachment=False)
upload_po_filename = fields.Char(string='PO Filename') upload_po_filename = fields.Char(string='PO Filename')
coating_config_id = fields.Many2one( # Renamed from coating_config_id (Phase E — Promote Customer Spec).
'fp.coating.config', string='Coating Configuration', required=True, # Now points at the recipe directly. The quote's specification
# (customer-facing audit ref) is added by quality inherit as
# customer_spec_id.
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
required=True,
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
) )
quantity = fields.Integer(string='Quantity', default=1, required=True) quantity = fields.Integer(string='Quantity', default=1, required=True)
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.') batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
@@ -345,10 +352,10 @@ class FpQuoteConfigurator(models.Model):
# Copy masking area too (for effective-area calculation) # Copy masking area too (for effective-area calculation)
self.masking_area_sqin = cat.masking_area_sqin self.masking_area_sqin = cat.masking_area_sqin
@api.onchange('coating_config_id') @api.onchange('recipe_id')
def _onchange_coating_config_id(self): def _onchange_recipe_id(self):
if self.coating_config_id: if self.recipe_id and self.recipe_id.thickness_min:
self.thickness_requested = self.coating_config_id.thickness_min self.thickness_requested = self.recipe_id.thickness_min
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Price calculation # Price calculation
@@ -358,11 +365,11 @@ class FpQuoteConfigurator(models.Model):
'masking_zones', 'complexity', 'substrate_material', 'masking_zones', 'complexity', 'substrate_material',
'quantity', 'batch_size', 'rush_order', 'quantity', 'batch_size', 'rush_order',
'shipping_fee', 'delivery_fee', 'shipping_fee', 'delivery_fee',
'coating_config_id', 'coating_config_id.certification_level', 'recipe_id',
) )
def _compute_price(self): def _compute_price(self):
for rec in self: for rec in self:
if not rec.coating_config_id or not rec.surface_area: if not rec.recipe_id or not rec.surface_area:
rec.calculated_price = 0 rec.calculated_price = 0
rec.price_breakdown_html = '' rec.price_breakdown_html = ''
continue continue
@@ -476,19 +483,17 @@ class FpQuoteConfigurator(models.Model):
def _find_matching_rule(self): def _find_matching_rule(self):
"""Find the best pricing rule matching this configurator's filters. """Find the best pricing rule matching this configurator's filters.
Scores rules by specificity -- most specific match wins. Scores rules by specificity most specific match wins.
If no rule matches filters, returns None. If no rule matches filters, returns None.
When the chosen coating config points at a recipe and that recipe When the chosen recipe has `pricing_rule_ids` configured, the
has `pricing_rule_ids` configured, the search is constrained to search is constrained to those rules ("Use Price Builders"
those rules ("Use Price Builders" semantics). Otherwise the semantics). Otherwise the whole active rule set is considered.
whole active rule set is considered as before.
Spec-tier scoring is added by an inherit in
fusion_plating_quality (where customer.spec lives).
""" """
recipe = ( recipe = self.recipe_id or False
self.coating_config_id.recipe_id
if self.coating_config_id and self.coating_config_id.recipe_id
else False
)
builder_rules = ( builder_rules = (
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule'] recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
) )
@@ -500,27 +505,15 @@ class FpQuoteConfigurator(models.Model):
rules = self.env['fp.pricing.rule'].search( rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id' [('active', '=', True)], order='sequence, id'
) )
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None best = None
best_score = -1 best_score = -1
for rule in rules: for rule in rules:
score = 0 score = 0
if rule.coating_config_id:
if rule.coating_config_id != self.coating_config_id:
continue
score += 4
if rule.substrate_material: if rule.substrate_material:
if rule.substrate_material != self.substrate_material: if rule.substrate_material != self.substrate_material:
continue continue
score += 2 score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score: if score > best_score:
best_score = score best_score = score
best = rule best = rule
@@ -569,9 +562,9 @@ class FpQuoteConfigurator(models.Model):
raise UserError(_( raise UserError(_(
'Pick a part catalog entry before promoting this quote.' 'Pick a part catalog entry before promoting this quote.'
)) ))
if not self.coating_config_id: if not self.recipe_id:
raise UserError(_( raise UserError(_(
'Pick a coating configuration before promoting this quote.' 'Pick a recipe before promoting this quote.'
)) ))
existing_line = self.env['fp.direct.order.line'].search([ existing_line = self.env['fp.direct.order.line'].search([
('quote_id', '=', self.id), ('quote_id', '=', self.id),
@@ -618,14 +611,13 @@ class FpQuoteConfigurator(models.Model):
'purchase_ok': False, 'purchase_ok': False,
}) })
coating_name = self.coating_config_id.name if self.coating_config_id else '' recipe_name = self.recipe_id.name if self.recipe_id else ''
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part' part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
so_vals = { so_vals = {
'partner_id': self.partner_id.id, 'partner_id': self.partner_id.id,
'x_fc_configurator_id': self.id, 'x_fc_configurator_id': self.id,
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False, 'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order, 'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method, 'x_fc_delivery_method': self.delivery_method,
# Transfer RFQ / PO documents from configurator (if any) # Transfer RFQ / PO documents from configurator (if any)
@@ -641,17 +633,19 @@ class FpQuoteConfigurator(models.Model):
'origin': self.name, 'origin': self.name,
'order_line': [(0, 0, { 'order_line': [(0, 0, {
'product_id': product.id, 'product_id': product.id,
'name': '%s%s (x%d)' % (coating_name, part_name, self.quantity), 'name': '%s%s (x%d)' % (recipe_name, part_name, self.quantity),
'product_uom_qty': self.quantity, 'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price, 'price_unit': price / self.quantity if self.quantity else price,
# Sub 11 fix — propagate part + coating to the LINE too. # Propagate part + recipe to the LINE.
# fusion_plating_jobs._fp_auto_create_job filters lines # fusion_plating_jobs._fp_auto_create_job filters lines
# by x_fc_part_catalog_id; without it, no fp.job spawns. # by x_fc_part_catalog_id; without it, no fp.job spawns.
# Spec carry-over to SO line is handled by the quality
# inherit (sale_order_line_inherit.create override).
'x_fc_part_catalog_id': ( 'x_fc_part_catalog_id': (
self.part_catalog_id.id if self.part_catalog_id else False self.part_catalog_id.id if self.part_catalog_id else False
), ),
'x_fc_coating_config_id': ( 'x_fc_process_variant_id': (
self.coating_config_id.id if self.coating_config_id else False self.recipe_id.id if self.recipe_id else False
), ),
})], })],
} }

View File

@@ -52,19 +52,14 @@ class FpSaleDescriptionTemplate(models.Model):
'part — it only appears in the picker when this part is on ' 'part — it only appears in the picker when this part is on '
'the order. Leave blank for generic fallback templates.', 'the order. Leave blank for generic fallback templates.',
) )
# Related fields — surface the part's partner/coating for search & # Related fields — surface the part's partner for search & grouping
# grouping without writing them twice. # without writing it twice.
partner_id = fields.Many2one( partner_id = fields.Many2one(
'res.partner', string='Customer', 'res.partner', string='Customer',
related='part_catalog_id.partner_id', store=True, readonly=True, related='part_catalog_id.partner_id', store=True, readonly=True,
) )
# Keep the explicit coating slot for global templates that aren't # coating_config_id removed; templates can be customer- or part-
# part-specific but are still coating-specific. # scoped. Spec-scoped templates are a future enhancement.
coating_config_id = fields.Many2one(
'fp.coating.config', string='Associated Coating',
ondelete='set null',
help='For generic (no-part) templates, restrict to one coating.',
)
tag = fields.Selection( tag = fields.Selection(
[('standard', 'Standard'), [('standard', 'Standard'),
('masking', 'Masking / Selective'), ('masking', 'Masking / Selective'),

View File

@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpTreatment(models.Model):
"""Pre- or post-treatment step (bead blast, zincate, bake, passivate, etc.).
Used by coating configurations to specify which preparation and
finishing steps are required for a given process.
"""
_name = 'fp.treatment'
_description = 'Fusion Plating — Treatment'
_order = 'treatment_type, sequence, name'
name = fields.Char(
string='Treatment',
required=True,
help='e.g. "Bead Blast", "Zincate", "Hydrogen Embrittlement Bake"',
)
treatment_type = fields.Selection(
[('pre', 'Pre-Treatment'), ('post', 'Post-Treatment')],
string='Type',
required=True,
default='pre',
)
sequence = fields.Integer(string='Sequence', default=10)
default_duration_minutes = fields.Float(
string='Default Duration (min)',
help='Estimated duration per application in minutes.',
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
required=True,
default=lambda self: self.env.company.currency_id,
)
default_cost = fields.Monetary(
string='Default Cost',
currency_field='currency_id',
help='Default cost per application. Can be overridden on pricing rules.',
)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)
_sql_constraints = [
('fp_treatment_name_type_uniq', 'unique(name, treatment_type)',
'Treatment name must be unique per type.'),
]

View File

@@ -11,7 +11,8 @@ class SaleOrder(models.Model):
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False) x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part') x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
x_fc_coating_config_id = fields.Many2one('fp.coating.config', string='Coating Configuration') # x_fc_coating_config_id removed; specs live on customer.spec via
# the line-level x_fc_customer_spec_id (added by quality inherit).
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True) x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
x_fc_po_attachment_id = fields.Many2one( x_fc_po_attachment_id = fields.Many2one(
'ir.attachment', string='PO Document', tracking=True, 'ir.attachment', string='PO Document', tracking=True,
@@ -209,7 +210,7 @@ class SaleOrder(models.Model):
for so in self: for so in self:
variants = [] variants = []
for line in so.order_line: for line in so.order_line:
if not (line.x_fc_part_catalog_id or line.x_fc_coating_config_id): if not line.x_fc_part_catalog_id:
continue # non-plating line continue # non-plating line
variant = (line.x_fc_process_variant_id variant = (line.x_fc_process_variant_id
or line.x_fc_part_catalog_id.default_process_id) or line.x_fc_part_catalog_id.default_process_id)
@@ -553,31 +554,16 @@ class SaleOrder(models.Model):
@api.depends('order_line.price_subtotal', 'amount_untaxed') @api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self): def _compute_margin(self):
"""Margin = untaxed total rolled-up cost from coating configs. """Margin computation — stub.
x_fc_margin_percent is stored as a fraction (0.0 - 1.0) so the Pre-promote-customer-spec, this rolled up cost from
widget='percentage' formats 100% as 100%, not 10000%. fp.coating.config.unit_cost. Coating Config is retired; cost
data on the recipe is a future enhancement (backlog). Until
x_fc_margin_available is False when NO line has a costed coating then, margin is "not available" and the UI hides the fields.
(i.e. fp.coating.config.unit_cost isn't populated anywhere). The
UI should render margin fields as "n/a" in that case rather than
showing a misleading 100%.
""" """
for rec in self: for rec in self:
has_cost_data = False rec.x_fc_margin_available = False
cost = 0.0 rec.x_fc_margin_amount = (rec.amount_untaxed or 0)
for line in rec.order_line:
cc = line.x_fc_coating_config_id
if not cc:
continue
if 'unit_cost' not in cc._fields:
continue
if cc.unit_cost:
has_cost_data = True
cost_per_unit = cc.unit_cost or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
rec.x_fc_margin_available = has_cost_data
rec.x_fc_margin_amount = (rec.amount_untaxed or 0) - cost
rec.x_fc_margin_percent = ( rec.x_fc_margin_percent = (
(rec.x_fc_margin_amount / rec.amount_untaxed) (rec.x_fc_margin_amount / rec.amount_untaxed)
if (rec.amount_untaxed and has_cost_data) else 0.0 if (rec.amount_untaxed and has_cost_data) else 0.0

View File

@@ -59,15 +59,9 @@ class SaleOrderLine(models.Model):
string='Description Template', string='Description Template',
help='Which template row populated this line. Informational.', help='Which template row populated this line. Informational.',
) )
x_fc_coating_config_id = fields.Many2one( # Specification picker (x_fc_customer_spec_id) is added by
'fp.coating.config', string='Primary Treatment', # fusion_plating_quality. Legacy x_fc_coating_config_id +
) # x_fc_treatment_ids removed.
# x_fc_customer_spec_id is added by fusion_plating_quality (where
# fusion.plating.customer.spec lives). Configurator can't reference
# it directly without a circular dep.
x_fc_treatment_ids = fields.Many2many(
'fp.treatment', string='Additional Treatments',
)
x_fc_part_deadline = fields.Date( x_fc_part_deadline = fields.Date(
string='Part Deadline Override', string='Part Deadline Override',
help='Absolute-date manual override. When set, beats the days-offset ' help='Absolute-date manual override. When set, beats the days-offset '

View File

@@ -1,13 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_treatment_operator,fp.treatment.operator,model_fp_treatment,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_treatment_supervisor,fp.treatment.supervisor,model_fp_treatment,fusion_plating.group_fusion_plating_supervisor,1,1,0,0
access_fp_treatment_manager,fp.treatment.manager,model_fp_treatment,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_config_operator,fp.coating.config.operator,model_fp_coating_config,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_coating_config_estimator,fp.coating.config.estimator,model_fp_coating_config,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_config_manager,fp.coating.config.manager,model_fp_coating_config,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
@@ -35,9 +29,6 @@ access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1 access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_customer_price_list_operator,fp.customer.price.list.operator,model_fp_customer_price_list,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_customer_price_list_estimator,fp.customer.price.list.estimator,model_fp_customer_price_list,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_customer_price_list_manager,fp.customer.price.list.manager,model_fp_customer_price_list,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0 access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0
access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
@@ -48,9 +39,6 @@ access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1 access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0 access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_fp_treatment_operator fp.treatment.operator model_fp_treatment fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_treatment_supervisor fp.treatment.supervisor model_fp_treatment fusion_plating.group_fusion_plating_supervisor 1 1 0 0
access_fp_treatment_manager fp.treatment.manager model_fp_treatment fusion_plating.group_fusion_plating_manager 1 1 1 1
2 access_fp_part_catalog_operator fp.part.catalog.operator model_fp_part_catalog fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_part_catalog_estimator fp.part.catalog.estimator model_fp_part_catalog fusion_plating_configurator.group_fp_estimator 1 1 1 0
4 access_fp_part_catalog_manager fp.part.catalog.manager model_fp_part_catalog fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_coating_config_operator fp.coating.config.operator model_fp_coating_config fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_coating_config_estimator fp.coating.config.estimator model_fp_coating_config fusion_plating_configurator.group_fp_estimator 1 1 1 0
access_fp_coating_config_manager fp.coating.config.manager model_fp_coating_config fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_pricing_rule_operator fp.pricing.rule.operator model_fp_pricing_rule fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_pricing_rule_estimator fp.pricing.rule.estimator model_fp_pricing_rule fusion_plating_configurator.group_fp_estimator 1 1 1 0
7 access_fp_pricing_rule_manager fp.pricing.rule.manager model_fp_pricing_rule fusion_plating.group_fusion_plating_manager 1 1 1 1
29 access_fp_sale_assembly_line_manager fp.sale.assembly.line.manager model_fp_sale_assembly_line fusion_plating.group_fusion_plating_manager 1 1 1 1
30 access_fp_part_import_wizard_estimator fp.part.catalog.import.wizard.estimator model_fp_part_catalog_import_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
31 access_fp_part_import_wizard_manager fp.part.catalog.import.wizard.manager model_fp_part_catalog_import_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_customer_price_list_operator fp.customer.price.list.operator model_fp_customer_price_list fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_customer_price_list_estimator fp.customer.price.list.estimator model_fp_customer_price_list fusion_plating_configurator.group_fp_estimator 1 1 1 0
access_fp_customer_price_list_manager fp.customer.price.list.manager model_fp_customer_price_list fusion_plating.group_fusion_plating_manager 1 1 1 1
32 access_fp_sale_desc_template_user fp.sale.description.template.user model_fp_sale_description_template base.group_user 1 0 0 0
33 access_fp_sale_desc_template_estimator fp.sale.description.template.estimator model_fp_sale_description_template fusion_plating_configurator.group_fp_estimator 1 1 1 0
34 access_fp_sale_desc_template_manager fp.sale.description.template.manager model_fp_sale_description_template fusion_plating.group_fusion_plating_manager 1 1 1 1
39 access_fp_serial_bulk_add_manager fp.serial.bulk.add.manager model_fp_serial_bulk_add_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
40 access_fp_part_revision_bump_estimator fp.part.revision.bump.estimator model_fp_part_revision_bump_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
41 access_fp_part_revision_bump_manager fp.part.revision.bump.manager model_fp_part_revision_bump_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1
42 access_fp_part_material_user fp.part.material.user model_fp_part_material base.group_user 1 0 0 0
43 access_fp_part_material_estimator fp.part.material.estimator model_fp_part_material fusion_plating_configurator.group_fp_estimator 1 1 1 0
44 access_fp_part_material_manager fp.part.material.manager model_fp_part_material fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -1,143 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Coating Configuration List View ===== -->
<record id="view_fp_coating_config_list" model="ir.ui.view">
<field name="name">fp.coating.config.list</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<list string="Coating Configurations" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="process_type_id"/>
<field name="phosphorus_level"/>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="spec_reference"/>
<field name="certification_level"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Coating Configuration Form View ===== -->
<record id="view_fp_coating_config_form" model="ir.ui.view">
<field name="name">fp.coating.config.form</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<form string="Coating Configuration">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. EN Mid-Phos AMS 2404"/></h1>
</div>
<group>
<group>
<field name="process_type_id"/>
<field name="recipe_id"/>
<field name="phosphorus_level"/>
<field name="certification_level"/>
<field name="sequence"/>
</group>
<group>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="thickness_uom"/>
<field name="spec_reference"/>
</group>
</group>
<notebook>
<page string="Treatments" name="treatments">
<group>
<group string="Pre-Treatments">
<field name="pre_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
<group string="Post-Treatments">
<field name="post_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
</group>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Detailed description of this coating configuration..."/>
</page>
<page string="Thickness Options" name="thickness_options">
<p class="text-muted">
Discrete thickness values the estimator can pick when
this coating appears on a sale order line. Each value
is driven by the spec this coating is built against
(e.g. AMS-2404 Class 4 → 0.0005″ / 0.001″ / 0.0015″).
Leave empty if no dropdown is needed for this coating.
</p>
<field name="thickness_option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<field name="uom"/>
<field name="display_name" string="Display" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</page>
</notebook>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Coating Configuration Search View ===== -->
<record id="view_fp_coating_config_search" model="ir.ui.view">
<field name="name">fp.coating.config.search</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="process_type_id"/>
<field name="spec_reference"/>
<separator/>
<filter string="Commercial" name="commercial" domain="[('certification_level','=','commercial')]"/>
<filter string="Mil-Spec" name="mil_spec" domain="[('certification_level','=','mil_spec')]"/>
<filter string="Nadcap" name="nadcap" domain="[('certification_level','=','nadcap')]"/>
<filter string="Nuclear" name="nuclear" domain="[('certification_level','=','nuclear')]"/>
<separator/>
<filter string="Low Phosphorus" name="low_phos" domain="[('phosphorus_level','=','low_phos')]"/>
<filter string="Mid Phosphorus" name="mid_phos" domain="[('phosphorus_level','=','mid_phos')]"/>
<filter string="High Phosphorus" name="high_phos" domain="[('phosphorus_level','=','high_phos')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Process Type" name="group_process_type" context="{'group_by':'process_type_id'}"/>
<filter string="Certification Level" name="group_cert_level" context="{'group_by':'certification_level'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_coating_config" model="ir.actions.act_window">
<field name="name">Coating Configurations</field>
<field name="res_model">fp.coating.config</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_coating_config_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No coating configurations defined yet
</p>
<p>
Define coating setups with process type, phosphorus level,
thickness range, spec reference, and required treatments.
</p>
</field>
</record>
</odoo>

View File

@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Standalone views for fp.coating.thickness so SO-line m2o pickers
can offer "Create and edit..." — the inline-on-coating-config
editor was the only way to add thicknesses pre-Sub-12d.
-->
<odoo>
<record id="view_fp_coating_thickness_list" model="ir.ui.view">
<field name="name">fp.coating.thickness.list</field>
<field name="model">fp.coating.thickness</field>
<field name="arch" type="xml">
<list string="Coating Thicknesses" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="coating_config_id"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min" optional="show"/>
<field name="value_max" string="Max" optional="show"/>
<field name="uom"/>
<field name="display_name" string="Label"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_coating_thickness_form" model="ir.ui.view">
<field name="name">fp.coating.thickness.form</field>
<field name="model">fp.coating.thickness</field>
<field name="arch" type="xml">
<form string="Coating Thickness">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="display_name" string="Thickness"/>
<h2><field name="display_name" readonly="1" placeholder="Auto-generated from value + UoM"/></h2>
</div>
<group>
<group string="Spec">
<field name="coating_config_id"
options="{'no_create_edit': True}"/>
<field name="value" string="Nominal"/>
<field name="uom"/>
</group>
<group string="Acceptance Band (optional)">
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<div colspan="2" class="text-muted">
Set Min/Max when the customer spec is a
range (e.g. AMS-2404 Class 4 = 0.001"0.0015").
QC readings outside the band fail.
</div>
</group>
<group>
<field name="sequence"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fp_coating_thickness_search" model="ir.ui.view">
<field name="name">fp.coating.thickness.search</field>
<field name="model">fp.coating.thickness</field>
<field name="arch" type="xml">
<search>
<field name="coating_config_id"/>
<field name="display_name"/>
<field name="uom"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Coating" name="group_coating"
context="{'group_by':'coating_config_id'}"/>
<filter string="UoM" name="group_uom"
context="{'group_by':'uom'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_coating_thickness" model="ir.actions.act_window">
<field name="name">Coating Thicknesses</field>
<field name="res_model">fp.coating.thickness</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_coating_thickness_search"/>
</record>
</odoo>

View File

@@ -87,30 +87,12 @@
sequence="8" sequence="8"
groups="group_fp_estimator"/> groups="group_fp_estimator"/>
<menuitem id="menu_fp_coating_configs"
name="Coating Configurations"
parent="menu_fp_configurator"
action="action_fp_coating_config"
sequence="20"/>
<menuitem id="menu_fp_pricing_rules" <menuitem id="menu_fp_pricing_rules"
name="Pricing Rules" name="Pricing Rules"
parent="menu_fp_configurator" parent="menu_fp_configurator"
action="action_fp_pricing_rule" action="action_fp_pricing_rule"
sequence="30"/> sequence="30"/>
<menuitem id="menu_fp_customer_price_lists"
name="Customer Price Lists"
parent="menu_fp_configurator"
action="action_fp_customer_price_list"
sequence="35"/>
<menuitem id="menu_fp_treatments"
name="Treatments"
parent="menu_fp_configurator"
action="action_fp_treatment"
sequence="40"/>
<menuitem id="menu_fp_part_materials" <menuitem id="menu_fp_part_materials"
name="Materials" name="Materials"
parent="menu_fp_configurator" parent="menu_fp_configurator"

View File

@@ -1,86 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_customer_price_list_list" model="ir.ui.view">
<field name="name">fp.customer.price.list.list</field>
<field name="model">fp.customer.price.list</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="currency_id" column_invisible="True"/>
<field name="unit_price" widget="monetary"
options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="price_uom"/>
<field name="min_quantity"/>
<field name="effective_from"/>
<field name="effective_to"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_customer_price_list_form" model="ir.ui.view">
<field name="name">fp.customer.price.list.form</field>
<field name="model">fp.customer.price.list</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h2><field name="name" readonly="1"/></h2>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="currency_id"/>
<field name="unit_price" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="price_uom"/>
</group>
<group>
<field name="effective_from"/>
<field name="effective_to"/>
<field name="min_quantity"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_customer_price_list_search" model="ir.ui.view">
<field name="name">fp.customer.price.list.search</field>
<field name="model">fp.customer.price.list</field>
<field name="arch" type="xml">
<search>
<field name="partner_id"/>
<field name="coating_config_id"/>
<filter name="active" string="Active"
domain="[('active', '=', True)]"/>
<filter name="expired" string="Expired"
domain="[('effective_to', '&lt;', context_today().strftime('%Y-%m-%d'))]"/>
<separator/>
<group>
<filter name="group_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="group_coating" string="Coating"
context="{'group_by': 'coating_config_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_customer_price_list" model="ir.actions.act_window">
<field name="name">Customer Price Lists</field>
<field name="res_model">fp.customer.price.list</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_customer_price_list_search"/>
<field name="context">{'search_default_active': 1}</field>
</record>
</odoo>

View File

@@ -201,20 +201,13 @@
class="btn-link"/> class="btn-link"/>
</list> </list>
</field> </field>
<separator string="Default Treatments" class="mt-4"/> <!-- Default Specification picker added by
<group> fusion_plating_quality view inherit. -->
<field name="x_fc_default_coating_config_id"
string="Default Treatment"
options="{'no_create_edit': True}"/>
<field name="x_fc_default_treatment_ids"
string="Default Additional Treatments"
widget="many2many_tags"
options="{'no_create_edit': True}"/>
</group>
<p class="text-muted"> <p class="text-muted">
Seeds the treatment fields on new direct-order Set a Default Specification on this part
lines for this part. Updated whenever "Save as (under the section added by the Quality
Default" is ticked while placing an order. module) so future direct-order lines
pre-fill it automatically.
</p> </p>
</page> </page>
<page string="Dimensions &amp; Complexity" name="dimensions"> <page string="Dimensions &amp; Complexity" name="dimensions">

View File

@@ -14,7 +14,6 @@
<list string="Pricing Rules" decoration-muted="not active"> <list string="Pricing Rules" decoration-muted="not active">
<field name="sequence" widget="handle"/> <field name="sequence" widget="handle"/>
<field name="name"/> <field name="name"/>
<field name="coating_config_id"/>
<field name="substrate_material"/> <field name="substrate_material"/>
<field name="certification_level"/> <field name="certification_level"/>
<field name="pricing_method"/> <field name="pricing_method"/>
@@ -42,7 +41,6 @@
</div> </div>
<group string="Filters"> <group string="Filters">
<group> <group>
<field name="coating_config_id"/>
<field name="substrate_material"/> <field name="substrate_material"/>
<field name="certification_level"/> <field name="certification_level"/>
</group> </group>
@@ -104,7 +102,6 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<search> <search>
<field name="name"/> <field name="name"/>
<field name="coating_config_id"/>
<separator/> <separator/>
<filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/> <filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/>
<filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/> <filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/>
@@ -113,7 +110,6 @@
<separator/> <separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group> <group>
<filter string="Coating Config" name="group_coating_config" context="{'group_by':'coating_config_id'}"/>
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/> <filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
</group> </group>
</search> </search>

View File

@@ -129,7 +129,7 @@
<group string="Customer &amp; Part"> <group string="Customer &amp; Part">
<field name="partner_id"/> <field name="partner_id"/>
<field name="part_catalog_id"/> <field name="part_catalog_id"/>
<field name="coating_config_id"/> <field name="recipe_id"/>
<!-- 3D File: upload before, filename + clear button after --> <!-- 3D File: upload before, filename + clear button after -->
<field name="upload_3d_file" filename="upload_3d_filename" <field name="upload_3d_file" filename="upload_3d_filename"
invisible="state != 'draft' or model_attachment_id" invisible="state != 'draft' or model_attachment_id"
@@ -325,7 +325,7 @@
<field name="create_date" string="Date"/> <field name="create_date" string="Date"/>
<field name="name"/> <field name="name"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="coating_config_id"/> <field name="recipe_id"/>
<field name="surface_area"/> <field name="surface_area"/>
<field name="quantity"/> <field name="quantity"/>
<field name="currency_id" column_invisible="1"/> <field name="currency_id" column_invisible="1"/>
@@ -350,14 +350,14 @@
<search> <search>
<field name="name"/> <field name="name"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="coating_config_id"/> <field name="recipe_id"/>
<separator/> <separator/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/> <filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/> <filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/> <filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<group> <group>
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/> <filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
<filter string="Coating Config" name="group_coating" context="{'group_by': 'coating_config_id'}"/> <filter string="Recipe" name="group_recipe" context="{'group_by': 'recipe_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/> <filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
</group> </group>
</search> </search>

View File

@@ -22,7 +22,6 @@
decoration-danger="tag == 'rework'" decoration-danger="tag == 'rework'"
decoration-success="tag in ('aerospace','nuclear')"/> decoration-success="tag in ('aerospace','nuclear')"/>
<field name="partner_id" optional="show"/> <field name="partner_id" optional="show"/>
<field name="coating_config_id" optional="hide"/>
<field name="usage_count" string="Used"/> <field name="usage_count" string="Used"/>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</list> </list>
@@ -46,9 +45,6 @@
<field name="tag"/> <field name="tag"/>
</group> </group>
<group> <group>
<field name="coating_config_id"
help="Only used for generic (no-part) templates."
invisible="part_catalog_id"/>
<field name="sequence"/> <field name="sequence"/>
<field name="usage_count" readonly="1"/> <field name="usage_count" readonly="1"/>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
@@ -75,7 +71,6 @@
<field name="internal_description"/> <field name="internal_description"/>
<field name="customer_facing_description"/> <field name="customer_facing_description"/>
<field name="part_catalog_id"/> <field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="tag"/> <field name="tag"/>
<filter name="active" string="Active" domain="[('active','=',True)]"/> <filter name="active" string="Active" domain="[('active','=',True)]"/>

View File

@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Treatment List View ===== -->
<record id="view_fp_treatment_list" model="ir.ui.view">
<field name="name">fp.treatment.list</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<list string="Treatments">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="treatment_type"/>
<field name="default_duration_minutes" string="Duration (min)"/>
<field name="currency_id" column_invisible="1"/>
<field name="default_cost" widget="monetary"
options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Treatment Form View ===== -->
<record id="view_fp_treatment_form" model="ir.ui.view">
<field name="name">fp.treatment.form</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<form string="Treatment">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Bead Blast"/></h1>
</div>
<group>
<group>
<field name="treatment_type"/>
<field name="sequence"/>
</group>
<group>
<label for="default_duration_minutes"/>
<div class="o_row">
<field name="default_duration_minutes" nolabel="1" class="oe_inline"/>
<span class="ms-1">min</span>
</div>
<field name="currency_id"/>
<field name="default_cost" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
</group>
</group>
<field name="description" placeholder="Description of this treatment step..."/>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Treatment Search View ===== -->
<record id="view_fp_treatment_search" model="ir.ui.view">
<field name="name">fp.treatment.search</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Pre-Treatment" name="pre" domain="[('treatment_type','=','pre')]"/>
<filter string="Post-Treatment" name="post" domain="[('treatment_type','=','post')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Type" name="group_type" context="{'group_by':'treatment_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_treatment" model="ir.actions.act_window">
<field name="name">Treatments</field>
<field name="res_model">fp.treatment</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_treatment_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No treatments defined yet
</p>
<p>
Add pre-treatment steps (bead blast, zincate, acid etch) and
post-treatment steps (bake, passivate, chromate seal).
</p>
</field>
</record>
</odoo>

View File

@@ -106,7 +106,7 @@
so you can confirm an order has the right parts/coatings so you can confirm an order has the right parts/coatings
without scrolling pricing columns. The pre-Sub-12 SO- without scrolling pricing columns. The pre-Sub-12 SO-
header singletons (x_fc_part_catalog_id / header singletons (x_fc_part_catalog_id /
x_fc_coating_config_id) only ever populated when the x_fc_customer_spec_id) only ever populated when the
order was built via the quote configurator — they're order was built via the quote configurator — they're
silent on direct orders, which is why they appeared silent on direct orders, which is why they appeared
empty after confirm. They still exist on the model empty after confirm. They still exist on the model
@@ -118,7 +118,6 @@
readonly="1"> readonly="1">
<list create="false" delete="false" edit="false"> <list create="false" delete="false" edit="false">
<field name="x_fc_part_catalog_id"/> <field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_thickness_id" optional="show"/> <field name="x_fc_thickness_id" optional="show"/>
<field name="x_fc_process_variant_id" optional="show" <field name="x_fc_process_variant_id" optional="show"
string="Process"/> string="Process"/>
@@ -251,7 +250,6 @@
<field name="x_fc_internal_description" <field name="x_fc_internal_description"
placeholder="Shop-floor workflow instructions (prints on WO / traveler)" placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
optional="hide"/> optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id" <field name="x_fc_process_variant_id"
string="Process / Recipe" string="Process / Recipe"
options="{'no_quick_create': True}" options="{'no_quick_create': True}"
@@ -290,7 +288,6 @@
<field name="x_fc_revision_snapshot" <field name="x_fc_revision_snapshot"
readonly="1" readonly="1"
optional="hide"/> optional="hide"/>
<field name="x_fc_treatment_ids" widget="many2many_tags" invisible="1"/>
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/> <field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/> <field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
<field name="x_fc_effective_part_deadline" string="Effective Deadline" <field name="x_fc_effective_part_deadline" string="Effective Deadline"
@@ -335,7 +332,6 @@
<field name="x_fc_wo_completion" optional="show"/> <field name="x_fc_wo_completion" optional="show"/>
<field name="x_fc_planned_start_date" optional="hide"/> <field name="x_fc_planned_start_date" optional="hide"/>
<field name="x_fc_part_catalog_id" optional="hide"/> <field name="x_fc_part_catalog_id" optional="hide"/>
<field name="x_fc_coating_config_id" optional="hide"/>
<field name="amount_total" sum="Total"/> <field name="amount_total" sum="Total"/>
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide" <field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
widget="monetary" widget="monetary"
@@ -363,7 +359,6 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<kanban default_group_by="x_fc_part_catalog_id" records_draggable="0"> <kanban default_group_by="x_fc_part_catalog_id" records_draggable="0">
<field name="x_fc_part_catalog_id"/> <field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/> <field name="product_uom_qty"/>
<field name="qty_delivered"/> <field name="qty_delivered"/>
<field name="x_fc_wo_group_tag"/> <field name="x_fc_wo_group_tag"/>
@@ -373,7 +368,7 @@
<t t-name="card"> <t t-name="card">
<div class="o_kanban_card_content"> <div class="o_kanban_card_content">
<div class="o_kanban_record_title"> <div class="o_kanban_record_title">
<strong><field name="x_fc_coating_config_id"/></strong> <strong><field name="x_fc_part_catalog_id"/></strong>
</div> </div>
<div class="text-muted"> <div class="text-muted">
Qty: <field name="product_uom_qty"/> Qty: <field name="product_uom_qty"/>
@@ -399,7 +394,6 @@
<kanban default_group_by="x_fc_wo_group_tag" records_draggable="0"> <kanban default_group_by="x_fc_wo_group_tag" records_draggable="0">
<field name="x_fc_wo_group_tag"/> <field name="x_fc_wo_group_tag"/>
<field name="x_fc_part_catalog_id"/> <field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/> <field name="product_uom_qty"/>
<templates> <templates>
<t t-name="card"> <t t-name="card">
@@ -407,9 +401,6 @@
<div> <div>
<strong><field name="x_fc_part_catalog_id"/></strong> <strong><field name="x_fc_part_catalog_id"/></strong>
</div> </div>
<div class="text-muted">
<field name="x_fc_coating_config_id"/>
</div>
<div> <div>
Qty: <field name="product_uom_qty"/> Qty: <field name="product_uom_qty"/>
</div> </div>

View File

@@ -43,14 +43,14 @@ class FpAddFromQuoteWizard(models.TransientModel):
wizard = self.direct_order_wizard_id wizard = self.direct_order_wizard_id
copied = 0 copied = 0
for q in self.quote_ids: for q in self.quote_ids:
if not q.part_catalog_id or not q.coating_config_id: if not q.part_catalog_id or not q.recipe_id:
continue continue
Line._create_from_quote(q, wizard) Line._create_from_quote(q, wizard)
copied += 1 copied += 1
if not copied: if not copied:
raise UserError(_( raise UserError(_(
'The selected quotes do not have both part and coating set, ' 'The selected quotes do not have both part and recipe set, '
'so nothing could be copied.' 'so nothing could be copied.'
)) ))

View File

@@ -22,7 +22,7 @@
<list> <list>
<field name="name"/> <field name="name"/>
<field name="part_catalog_id"/> <field name="part_catalog_id"/>
<field name="coating_config_id"/> <field name="recipe_id"/>
<field name="quantity"/> <field name="quantity"/>
<field name="calculated_price" widget="monetary"/> <field name="calculated_price" widget="monetary"/>
<field name="estimator_override_price" widget="monetary"/> <field name="estimator_override_price" widget="monetary"/>

View File

@@ -53,14 +53,12 @@ class FpAddFromSoWizard(models.TransientModel):
wizard = self.direct_order_wizard_id wizard = self.direct_order_wizard_id
copied = 0 copied = 0
for src in self.source_line_ids: for src in self.source_line_ids:
if not src.x_fc_part_catalog_id or not src.x_fc_coating_config_id: if not src.x_fc_part_catalog_id:
# Skip SO lines that predate the plating fields # Skip non-plating SO lines
continue continue
Line.create({ Line.create({
'wizard_id': wizard.id, 'wizard_id': wizard.id,
'part_catalog_id': src.x_fc_part_catalog_id.id, 'part_catalog_id': src.x_fc_part_catalog_id.id,
'coating_config_id': src.x_fc_coating_config_id.id,
'treatment_ids': [(6, 0, src.x_fc_treatment_ids.ids)],
'quantity': int(src.product_uom_qty) or 1, 'quantity': int(src.product_uom_qty) or 1,
'unit_price': src.price_unit or 0.0, 'unit_price': src.price_unit or 0.0,
'part_deadline': src.x_fc_part_deadline, 'part_deadline': src.x_fc_part_deadline,

View File

@@ -27,7 +27,7 @@
<list> <list>
<field name="name"/> <field name="name"/>
<field name="x_fc_part_catalog_id"/> <field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/> <field name="x_fc_part_deadline" optional="hide"/>
<field name="product_uom_qty"/> <field name="product_uom_qty"/>
<field name="price_unit"/> <field name="price_unit"/>
<field name="x_fc_part_deadline"/> <field name="x_fc_part_deadline"/>

View File

@@ -51,22 +51,9 @@ class FpDirectOrderLine(models.Model):
new_drawing_filename = fields.Char(string='Filename') new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(string='Revision Note') revision_note = fields.Char(string='Revision Note')
# ---- Treatments ---- # Specification picker (customer_spec_id) added by
coating_config_id = fields.Many2one( # fusion_plating_quality. Legacy coating_config_id +
'fp.coating.config', # treatment_ids removed.
string='Primary Treatment',
help='Optional. Some orders are non-coating work (re-inspection, '
'rework, masking-only, etc.) and the operator picks the '
'workflow downstream — leaving this blank lets that path '
'through.',
)
# customer_spec_id is added by fusion_plating_quality (where
# fusion.plating.customer.spec lives).
treatment_ids = fields.Many2many(
'fp.treatment',
string='Additional Treatments',
help='Extra pre/post treatments applied to this line.',
)
# Sub 9 (polished 2026-04-28) — process variant per line. The picker # Sub 9 (polished 2026-04-28) — process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the # now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template # part's own variants, another customer's variants, or a template
@@ -107,8 +94,7 @@ class FpDirectOrderLine(models.Model):
) )
@api.depends('process_variant_id', @api.depends('process_variant_id',
'part_catalog_id.default_process_id', 'part_catalog_id.default_process_id')
'coating_config_id.recipe_id')
def _compute_effective_process(self): def _compute_effective_process(self):
for rec in self: for rec in self:
if rec.process_variant_id: if rec.process_variant_id:
@@ -122,12 +108,6 @@ class FpDirectOrderLine(models.Model):
rec.effective_process_id = part_proc rec.effective_process_id = part_proc
rec.effective_process_source = 'Part default' rec.effective_process_source = 'Part default'
continue continue
cc_proc = (rec.coating_config_id.recipe_id
if rec.coating_config_id else False)
if cc_proc:
rec.effective_process_id = cc_proc
rec.effective_process_source = 'Coating default'
continue
rec.effective_process_id = False rec.effective_process_id = False
rec.effective_process_source = False rec.effective_process_source = False
@@ -168,35 +148,26 @@ class FpDirectOrderLine(models.Model):
if not rec.part_catalog_id: if not rec.part_catalog_id:
continue continue
part = rec.part_catalog_id part = rec.part_catalog_id
has_default_coating = bool(getattr(
part, 'x_fc_default_coating_config_id', False))
has_default_treatments = bool(getattr(
part, 'x_fc_default_treatment_ids', False))
# Pre-fill default coating if the line is empty.
if not rec.coating_config_id and has_default_coating:
rec.coating_config_id = part.x_fc_default_coating_config_id
# Pre-fill default treatments if any are configured.
if not rec.treatment_ids and has_default_treatments:
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
# Default-spec auto-fill is implemented by an inherit in # Default-spec auto-fill is implemented by an inherit in
# fusion_plating_quality (where customer_spec_id field lives). # fusion_plating_quality (where customer_spec_id field lives).
# New-part auto-suggest: if neither default exists, this is has_default_spec = bool(getattr(
part, 'x_fc_default_customer_spec_id', False))
# New-part auto-suggest: if no default spec exists, this is
# likely a first-time use of the part. Auto-tick the # likely a first-time use of the part. Auto-tick the
# push_to_defaults toggle so whatever Sarah picks becomes # push_to_defaults toggle so whatever Sarah picks becomes
# the saved default — surface a warning popup so she knows. # the saved default — surface a warning popup so she knows.
# `is_one_off` always wins (operator opted out of catalog # `is_one_off` always wins (operator opted out of catalog
# persistence), so don't auto-tick in that case. # persistence), so don't auto-tick in that case.
if (not has_default_coating if (not has_default_spec
and not has_default_treatments
and not rec.is_one_off and not rec.is_one_off
and not rec.push_to_defaults): and not rec.push_to_defaults):
rec.push_to_defaults = True rec.push_to_defaults = True
warning = { warning = {
'title': _('First-Time Part — Defaults Will Be Saved'), 'title': _('First-Time Part — Defaults Will Be Saved'),
'message': _( 'message': _(
'%(part)s has no saved coating / treatments. ' '%(part)s has no saved specification. '
'The coating + treatments you pick on this line ' 'The specification you pick on this line will '
'will be saved as the part\'s defaults so the ' 'be saved as the part\'s default so the '
'next order auto-fills them. Untick "Save as ' 'next order auto-fills them. Untick "Save as '
'Default" on the line if you don\'t want this.' 'Default" on the line if you don\'t want this.'
) % {'part': part.display_name or part.part_number or '(part)'}, ) % {'part': part.display_name or part.part_number or '(part)'},
@@ -269,11 +240,11 @@ class FpDirectOrderLine(models.Model):
start_at_node_id = fields.Many2one( start_at_node_id = fields.Many2one(
'fusion.plating.process.node', 'fusion.plating.process.node',
string='Start at Node', string='Start at Node',
domain="[('id', 'child_of', coating_config_id and coating_config_id.recipe_id.id or 0)]", domain="[('id', 'child_of', process_variant_id and process_variant_id.id or 0)]",
help='For re-work jobs: pick the recipe step where this job should ' help='For re-work jobs: pick the recipe step where this job should '
'begin. Pick a coating first — nodes are scoped to its ' 'begin. Pick a recipe first — nodes are scoped to it. Skips '
'recipe tree. Skips earlier steps in the generated WO but ' 'earlier steps in the generated WO but keeps later siblings '
'keeps later siblings and sub-processes.', 'and sub-processes.',
) )
is_one_off = fields.Boolean( is_one_off = fields.Boolean(
string='One-off Part', string='One-off Part',
@@ -436,12 +407,11 @@ class FpDirectOrderLine(models.Model):
for rec in self: for rec in self:
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0) rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
@api.depends('part_catalog_id', 'coating_config_id', 'unit_price', 'quantity') @api.depends('part_catalog_id', 'unit_price', 'quantity')
def _compute_is_missing_info(self): def _compute_is_missing_info(self):
for rec in self: for rec in self:
rec.is_missing_info = not ( rec.is_missing_info = not (
rec.part_catalog_id rec.part_catalog_id
and rec.coating_config_id
and rec.unit_price and rec.unit_price
and rec.quantity and rec.quantity
) )
@@ -499,14 +469,16 @@ class FpDirectOrderLine(models.Model):
# ---- Onchange ---- # ---- Onchange ----
@api.onchange('quote_id') @api.onchange('quote_id')
def _onchange_quote_id(self): def _onchange_quote_id(self):
"""Auto-fill part, coating, and unit price from the linked quote.""" """Auto-fill part and unit price from the linked quote.
Spec carry-over from quote → wizard line is handled by an
inherit in fusion_plating_quality.
"""
if not self.quote_id: if not self.quote_id:
return return
q = self.quote_id q = self.quote_id
if q.part_catalog_id and not self.part_catalog_id: if q.part_catalog_id and not self.part_catalog_id:
self.part_catalog_id = q.part_catalog_id self.part_catalog_id = q.part_catalog_id
if q.coating_config_id and not self.coating_config_id:
self.coating_config_id = q.coating_config_id
if not self.unit_price: if not self.unit_price:
final = q.estimator_override_price or q.calculated_price final = q.estimator_override_price or q.calculated_price
if final and q.quantity: if final and q.quantity:
@@ -514,13 +486,13 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id') @api.onchange('part_catalog_id')
def _onchange_part_defaults(self): def _onchange_part_defaults(self):
"""When a part is picked, seed coating + treatments from its catalog defaults.""" """Seed defaults when a part is picked.
Spec auto-fill is handled by an inherit in fusion_plating_quality
(the customer_spec_id field lives there).
"""
if not self.part_catalog_id: if not self.part_catalog_id:
return return
if not self.coating_config_id and self.part_catalog_id.x_fc_default_coating_config_id:
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
# Seed default taxes from the FP-SERVICE product, fiscal-position # Seed default taxes from the FP-SERVICE product, fiscal-position
# mapped from the customer. Only fills when the user hasn't set # mapped from the customer. Only fills when the user hasn't set
# taxes manually. # taxes manually.
@@ -543,21 +515,10 @@ class FpDirectOrderLine(models.Model):
if taxes: if taxes:
self.tax_ids = [(6, 0, taxes.ids)] self.tax_ids = [(6, 0, taxes.ids)]
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id') # Auto-fill unit_price from a customer price list — extended in
def _onchange_lookup_price(self): # fusion_plating_quality (the spec field lives there). The base
"""Auto-fill unit_price from customer price list when available.""" # configurator wizard no longer triggers price lookup since
if self.unit_price: # coating_config_id is gone.
return
partner = self.wizard_id.partner_id
if not (partner and self.coating_config_id):
return
price = self.env['fp.customer.price.list']._find_price(
partner.id,
self.coating_config_id.id,
quantity=self.quantity or 1,
)
if price:
self.unit_price = price.unit_price
@api.onchange('description_template_id') @api.onchange('description_template_id')
def _onchange_description_template(self): def _onchange_description_template(self):
@@ -575,15 +536,14 @@ class FpDirectOrderLine(models.Model):
if tpl.internal_description: if tpl.internal_description:
self.internal_description = tpl.internal_description self.internal_description = tpl.internal_description
@api.onchange('part_catalog_id', 'coating_config_id') @api.onchange('part_catalog_id')
def _onchange_suggest_template(self): def _onchange_suggest_template(self):
"""Offer a sensible default template — part-specific wins. """Offer a sensible default template — part-specific wins.
Priority (first non-empty result wins): Priority (first non-empty result wins):
1. This part's lowest-sequence active template 1. This part's lowest-sequence active template
2. This customer's templates (no part) 2. This customer's templates (no part)
3. This coating's templates (no part) 3. Don't auto-pick — user has to choose
4. Don't auto-pick — user has to choose
""" """
if self.description_template_id or self.line_description: if self.description_template_id or self.line_description:
return return
@@ -616,16 +576,6 @@ class FpDirectOrderLine(models.Model):
_apply(match) _apply(match)
return return
if self.coating_config_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', False),
('coating_config_id', '=', self.coating_config_id.id),
], order='sequence', limit=1)
if match:
_apply(match)
# ---- Helpers ---- # ---- Helpers ----
@api.model @api.model
def _create_from_quote(self, quote, wizard): def _create_from_quote(self, quote, wizard):
@@ -635,16 +585,17 @@ class FpDirectOrderLine(models.Model):
the bulk "Add From Quotes" sub-wizard — keeps the field mapping the bulk "Add From Quotes" sub-wizard — keeps the field mapping
in one place so the two flows can never drift. in one place so the two flows can never drift.
""" """
if not quote.part_catalog_id or not quote.coating_config_id: if not quote.part_catalog_id:
raise UserError(_( raise UserError(_(
'Quote %s has no part or coating set; cannot seed a line.' 'Quote %s has no part set; cannot seed a line.'
) % (quote.name or quote.id)) ) % (quote.name or quote.id))
final = quote.estimator_override_price or quote.calculated_price final = quote.estimator_override_price or quote.calculated_price
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0 unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
# Spec carry-over from quote → wizard line is handled by an
# inherit in fusion_plating_quality (customer_spec_id field).
return self.create({ return self.create({
'wizard_id': wizard.id, 'wizard_id': wizard.id,
'part_catalog_id': quote.part_catalog_id.id, 'part_catalog_id': quote.part_catalog_id.id,
'coating_config_id': quote.coating_config_id.id,
'quantity': int(quote.quantity) or 1, 'quantity': int(quote.quantity) or 1,
'unit_price': unit, 'unit_price': unit,
'quote_id': quote.id, 'quote_id': quote.id,

View File

@@ -550,12 +550,13 @@ class FpDirectOrderWizard(models.Model):
for line in self.line_ids: for line in self.line_ids:
part = line._get_or_bump_revision() part = line._get_or_bump_revision()
resolved_parts[line.id] = part resolved_parts[line.id] = part
# Build the line header. Primary treatment is optional now; # Build the line header. Specification is optional; when
# when missing, drop it from the header rather than printing # missing, drop it from the header rather than printing
# "False - PartName Rev A". # "False - PartName Rev A".
treatment_label = line.coating_config_id.name or _('No coating') spec = getattr(line, 'customer_spec_id', False)
spec_label = (spec.display_name if spec else '') or _('No spec')
header = '%s - %s Rev %s (x%d)' % ( header = '%s - %s Rev %s (x%d)' % (
treatment_label, spec_label,
part.name, part.name,
part.revision, part.revision,
line.quantity, line.quantity,
@@ -573,10 +574,9 @@ class FpDirectOrderWizard(models.Model):
'x_fc_part_catalog_id': part.id, 'x_fc_part_catalog_id': part.id,
'x_fc_description_template_id': line.description_template_id.id or False, 'x_fc_description_template_id': line.description_template_id.id or False,
'x_fc_internal_description': line.internal_description or False, 'x_fc_internal_description': line.internal_description or False,
'x_fc_coating_config_id': line.coating_config_id.id, # x_fc_customer_spec_id is set on the resulting SO line
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)], # by an extension in fusion_plating_quality (post-create
# x_fc_customer_spec_id is added to vals by an extension # patch — see fp_direct_order_line_inherit.py).
# of this method in fusion_plating_quality.
'x_fc_part_deadline': line.part_deadline, 'x_fc_part_deadline': line.part_deadline,
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days, 'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
'x_fc_rush_order': line.rush_order, 'x_fc_rush_order': line.rush_order,
@@ -630,19 +630,9 @@ class FpDirectOrderWizard(models.Model):
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.' 'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
) % {'doo': self.name, 'so': so.name}) ) % {'doo': self.name, 'so': so.name})
# 6. Push-to-defaults (C4) — uses the resolved part cached # 6. Push-to-defaults — Specification carry-over to the part's
# during the build loop so rev-bumped lines write defaults to # x_fc_default_customer_spec_id is handled by an inherit in
# the NEW revision, not the pre-bump one. # fusion_plating_quality (the field lives there).
for line in self.line_ids:
if not line.push_to_defaults or line.is_one_off:
continue
part = resolved_parts.get(line.id) or line.part_catalog_id
if not part:
continue
part.write({
'x_fc_default_coating_config_id': line.coating_config_id.id or False,
'x_fc_default_treatment_ids': [(6, 0, line.treatment_ids.ids)],
})
so.message_post(body=_( so.message_post(body=_(
'Quotation created from PO %s with %d line(s). ' 'Quotation created from PO %s with %d line(s). '
'Review and confirm manually when ready.' 'Review and confirm manually when ready.'

View File

@@ -154,8 +154,6 @@
optional="hide"/> optional="hide"/>
<field name="internal_description" <field name="internal_description"
optional="hide"/> optional="hide"/>
<field name="coating_config_id"
optional="show"/>
<field name="process_variant_id" <field name="process_variant_id"
string="Process / Recipe" string="Process / Recipe"
options="{'no_quick_create': True}" options="{'no_quick_create': True}"
@@ -194,9 +192,6 @@
class="btn-link" class="btn-link"
invisible="not part_catalog_id or serial_count &gt; 0"/> invisible="not part_catalog_id or serial_count &gt; 0"/>
<field name="job_number" optional="hide"/> <field name="job_number" optional="hide"/>
<field name="treatment_ids"
widget="many2many_tags"
invisible="1"/>
<field name="quantity" <field name="quantity"
optional="show"/> optional="show"/>
<field name="unit_price" <field name="unit_price"
@@ -239,9 +234,6 @@
invisible="not part_catalog_id"/> invisible="not part_catalog_id"/>
<field name="part_revision" <field name="part_revision"
invisible="not part_catalog_id"/> invisible="not part_catalog_id"/>
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"/>
<field name="process_variant_id" <field name="process_variant_id"
string="Process / Recipe" string="Process / Recipe"
options="{'no_quick_create': True}" options="{'no_quick_create': True}"

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.9.1.0', 'version': '19.0.10.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',
@@ -39,7 +39,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating', # fp.job, fp.job.step, fp.work.centre 'fusion_plating', # fp.job, fp.job.step, fp.work.centre
'fusion_plating_batch', # fusion.plating.batch (Phase 3) 'fusion_plating_batch', # fusion.plating.batch (Phase 3)
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading 'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
'fusion_plating_configurator', # fp.part.catalog, fp.coating.config 'fusion_plating_configurator', # fp.part.catalog
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4) 'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
'fusion_plating_logistics', # fusion.plating.delivery 'fusion_plating_logistics', # fusion.plating.delivery
'fusion_plating_notifications', # fp.notification.template (Phase 4) 'fusion_plating_notifications', # fp.notification.template (Phase 4)

View File

@@ -48,15 +48,12 @@ class FpJob(models.Model):
string='Part', string='Part',
ondelete='restrict', ondelete='restrict',
) )
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
ondelete='restrict',
)
customer_spec_id = fields.Many2one( customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', 'fusion.plating.customer.spec',
string='Customer Spec', string='Specification',
ondelete='set null', ondelete='set null',
help='Customer / industry spec the job ships under. Auto-filled '
'from the SO line at job creation.',
) )
portal_job_id = fields.Many2one( portal_job_id = fields.Many2one(
'fusion.plating.portal.job', 'fusion.plating.portal.job',
@@ -996,29 +993,28 @@ class FpJob(models.Model):
if node.estimated_duration: if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration vals['dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when # Pull thickness target from the recipe root when this
# this is a plating step (matched by node name keyword). # is a plating step (matched by node name keyword).
coating = job.coating_config_id # Recipe-root carries thickness fields post-promote-spec.
recipe_root = job.recipe_id
name_l = (node.name or '').lower() name_l = (node.name or '').lower()
is_plating_node = ( is_plating_node = (
'plat' in name_l or 'nickel' in name_l 'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l or 'chrome' in name_l or 'anodiz' in name_l
) )
if coating and is_plating_node: if recipe_root and is_plating_node:
if ( if (
'thickness_max' in coating._fields 'thickness_max' in recipe_root._fields
and coating.thickness_max and recipe_root.thickness_max
): ):
vals['thickness_target'] = coating.thickness_max vals['thickness_target'] = recipe_root.thickness_max
if ( if (
'thickness_uom' in coating._fields 'thickness_uom' in recipe_root._fields
and coating.thickness_uom and recipe_root.thickness_uom
): ):
# fp.coating.config uses long-form uom names # Recipe uses long-form uom names (mils /
# (mils / microns / inches); fp.job.step uses # microns / inches); fp.job.step uses short
# short codes (mil / um / inch). Map between # codes (mil / um / inch). Map between them.
# them. Unknown values fall through to the
# step's default ('um').
_UOM_MAP = { _UOM_MAP = {
'mils': 'mil', 'mils': 'mil',
'mil': 'mil', 'mil': 'mil',
@@ -1029,7 +1025,7 @@ class FpJob(models.Model):
'inch': 'inch', 'inch': 'inch',
'in': 'inch', 'in': 'inch',
} }
mapped = _UOM_MAP.get(coating.thickness_uom) mapped = _UOM_MAP.get(recipe_root.thickness_uom)
if mapped: if mapped:
vals['thickness_uom'] = mapped vals['thickness_uom'] = mapped
@@ -1546,7 +1542,9 @@ class FpJob(models.Model):
if not required: if not required:
return return
has_job_link = 'x_fc_job_id' in Cert._fields has_job_link = 'x_fc_job_id' in Cert._fields
coating = self.coating_config_id # Spec drives the cert spec_reference. The customer.spec was
# auto-filled onto the job at confirm time (sale_order.py).
spec = self.customer_spec_id
for cert_type in sorted(required): for cert_type in sorted(required):
# Idempotency per type. # Idempotency per type.
existing_dom = [('certificate_type', '=', cert_type)] existing_dom = [('certificate_type', '=', cert_type)]
@@ -1574,9 +1572,16 @@ class FpJob(models.Model):
if 'sale_order_id' in Cert._fields and self.sale_order_id: if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id vals['sale_order_id'] = self.sale_order_id.id
# spec_reference is what action_issue blocks on. # spec_reference is what action_issue blocks on.
if coating and 'spec_reference' in Cert._fields \ # Format spec.code + revision for the cert text.
and getattr(coating, 'spec_reference', False): if spec and 'spec_reference' in Cert._fields:
vals['spec_reference'] = coating.spec_reference ref = spec.code or ''
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
if ref:
vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields:
vals['customer_spec_id'] = spec.id
if 'part_number' in Cert._fields and self.part_catalog_id: if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = ( vals['part_number'] = (
self.part_catalog_id.part_number or '' self.part_catalog_id.part_number or ''

View File

@@ -474,8 +474,9 @@ class FpJobStep(models.Model):
def button_finish(self): def button_finish(self):
"""Override to: """Override to:
1) Auto-spawn a bake.window when a wet plating step finishes 1) Auto-spawn a bake.window when a wet plating step finishes
on a coating that requires hydrogen-embrittlement relief on a recipe that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance); (AS9100 / Nadcap compliance). Bake fields live on the
recipe root post-promote-customer-spec.
2) Post a chatter warning when duration_actual exceeds 1.5× 2) Post a chatter warning when duration_actual exceeds 1.5×
duration_expected — silent overruns are a red flag for duration_expected — silent overruns are a red flag for
scheduling and costing. scheduling and costing.
@@ -499,12 +500,11 @@ class FpJobStep(models.Model):
'estimate too tight.' 'estimate too tight.'
)) % (step.name, ratio, step.duration_expected, )) % (step.name, ratio, step.duration_expected,
step.duration_actual)) step.duration_actual))
coating = step.job_id.coating_config_id \ recipe_root = step.job_id.recipe_id
if 'coating_config_id' in step.job_id._fields else False if not recipe_root:
if not coating:
continue continue
requires = getattr(coating, 'requires_bake_relief', False) requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(coating, 'bake_window_hours', 0.0) window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs: if not requires or not window_hrs:
continue continue
# Trigger only on the actual plating-out step. We want # Trigger only on the actual plating-out step. We want

View File

@@ -339,11 +339,8 @@ class SaleOrder(models.Model):
1. line.x_fc_process_variant_id — Sarah explicitly picked a 1. line.x_fc_process_variant_id — Sarah explicitly picked a
part-scoped variant on this order line. Always wins. part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default 2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe; must beat any variant. Customer-and-part-tuned recipe.
generic coating template. 3. part.recipe_id — legacy fallback.
3. coating.recipe_id — coating-config recipe
(generic template fallback).
4. part.recipe_id — legacy fallback.
Returns the recipe record or an empty recordset. Returns the recipe record or an empty recordset.
""" """
Node = self.env['fusion.plating.process.node'] Node = self.env['fusion.plating.process.node']
@@ -352,11 +349,6 @@ class SaleOrder(models.Model):
) or False ) or False
if not part and 'x_fc_part_catalog_id' in self._fields: if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False part = self.x_fc_part_catalog_id or False
coating = (
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
) or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
picked = ( picked = (
'x_fc_process_variant_id' in line._fields 'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id and line.x_fc_process_variant_id
@@ -365,8 +357,6 @@ class SaleOrder(models.Model):
return picked return picked
if part and 'default_process_id' in part._fields and part.default_process_id: if part and 'default_process_id' in part._fields and part.default_process_id:
return part.default_process_id return part.default_process_id
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
return coating.recipe_id
if part and 'recipe_id' in part._fields and part.recipe_id: if part and 'recipe_id' in part._fields and part.recipe_id:
return part.recipe_id return part.recipe_id
return Node return Node
@@ -389,22 +379,22 @@ class SaleOrder(models.Model):
if existing: if existing:
return return
# Find plating lines (those with a part_catalog_id or coating_config_id) # Find plating lines (those with a part_catalog_id or
# customer_spec_id).
plating_lines = self.order_line.filtered( plating_lines = self.order_line.filtered(
lambda l: ( lambda l: (
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id) ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id) or ('x_fc_customer_spec_id' in l._fields and l.x_fc_customer_spec_id)
) )
) )
# Fallback: legacy/configurator SOs that carry part+coating on the # Fallback: SOs that carry part on the header but not on the
# header but not on the line. Treat the entire order as one # line. Treat the entire order as one plating job so the planner
# plating line so the planner gets an fp.job to work against. # gets an fp.job to work against.
if not plating_lines and self.order_line and ( if not plating_lines and self.order_line and (
('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id) 'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
): ):
_logger.info( _logger.info(
'SO %s: no line-level part/coating but header carries one — ' 'SO %s: no line-level part but header carries one — '
'treating all lines as a single plating job.', self.name, 'treating all lines as a single plating job.', self.name,
) )
plating_lines = self.order_line plating_lines = self.order_line
@@ -412,13 +402,12 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name) _logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return return
# Group by (recipe, part, coating, thickness, serial). Lines that # Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning # share ALL FIVE collapse into one WO. Bundling lines with
# as part_id + coating_id: bundling lines with different thicknesses # different specs / thicknesses / serials under one WO would
# or different serials under one WO would carry the first line's # carry the first line's values onto the cert + sticker —
# values onto the cert + sticker — silent mis-attestation. Sub 5 # silent mis-attestation. No-recipe lines still get their own
# added thickness_id + serial_id; this extends the grouping logic # group each.
# to honour them. No-recipe lines still get their own group each.
groups = {} groups = {}
unrecipe_idx = 0 unrecipe_idx = 0
for line in plating_lines: for line in plating_lines:
@@ -427,9 +416,9 @@ class SaleOrder(models.Model):
'x_fc_part_catalog_id' in line._fields 'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id and line.x_fc_part_catalog_id.id
) or False ) or False
coating_id = ( spec_id = (
'x_fc_coating_config_id' in line._fields 'x_fc_customer_spec_id' in line._fields
and line.x_fc_coating_config_id.id and line.x_fc_customer_spec_id.id
) or False ) or False
thickness_id = ( thickness_id = (
'x_fc_thickness_id' in line._fields 'x_fc_thickness_id' in line._fields
@@ -440,7 +429,7 @@ class SaleOrder(models.Model):
and line.x_fc_serial_id.id and line.x_fc_serial_id.id
) or False ) or False
if recipe: if recipe:
key = (recipe.id, part_id, coating_id, thickness_id, serial_id) key = (recipe.id, part_id, spec_id, thickness_id, serial_id)
else: else:
unrecipe_idx += 1 unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx) key = ('no_recipe', unrecipe_idx)
@@ -465,11 +454,6 @@ class SaleOrder(models.Model):
and first_line.x_fc_part_catalog_id and first_line.x_fc_part_catalog_id
or False or False
) )
coating = (
'x_fc_coating_config_id' in first_line._fields
and first_line.x_fc_coating_config_id
or False
)
customer_spec = ( customer_spec = (
'x_fc_customer_spec_id' in first_line._fields 'x_fc_customer_spec_id' in first_line._fields
and first_line.x_fc_customer_spec_id and first_line.x_fc_customer_spec_id
@@ -477,8 +461,6 @@ class SaleOrder(models.Model):
) )
if not part and 'x_fc_part_catalog_id' in self._fields: if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
recipe = self._fp_resolve_recipe_for_line(first_line) recipe = self._fp_resolve_recipe_for_line(first_line)
vals = { vals = {
@@ -492,8 +474,6 @@ class SaleOrder(models.Model):
} }
if part: if part:
vals['part_catalog_id'] = part.id vals['part_catalog_id'] = part.id
if coating:
vals['coating_config_id'] = coating.id
if customer_spec: if customer_spec:
vals['customer_spec_id'] = customer_spec.id vals['customer_spec_id'] = customer_spec.id
if recipe: if recipe:

View File

@@ -56,7 +56,6 @@
<t t-set="_so" t-value="job.sale_order_id"/> <t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/> <t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/> <t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/> <t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/> <t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/> <t t-set="_due" t-value="job.date_deadline or False"/>
@@ -99,7 +98,6 @@
<t t-set="_so" t-value="job.sale_order_id"/> <t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/> <t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/> <t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/> <t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/> <t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/> <t t-set="_due" t-value="job.date_deadline or False"/>

View File

@@ -203,9 +203,6 @@
<t t-if="'customer_spec_id' in job._fields and job.customer_spec_id"> <t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
<span t-esc="job.customer_spec_id.display_name"/> <span t-esc="job.customer_spec_id.display_name"/>
</t> </t>
<t t-elif="'coating_config_id' in job._fields and job.coating_config_id">
<span t-esc="job.coating_config_id.name"/>
</t>
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -95,7 +95,7 @@
</xpath> </xpath>
<xpath expr="//field[@name='product_id']" position="after"> <xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/> <field name="part_catalog_id" string="Part"/>
<field name="coating_config_id" string="Coating"/> <field name="customer_spec_id" string="Specification"/>
<field name="recipe_id" string="Process Recipe"/> <field name="recipe_id" string="Process Recipe"/>
</xpath> </xpath>
<!-- Show qty completed alongside total so the partial-qty <!-- Show qty completed alongside total so the partial-qty

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Quality (QMS)', 'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.5.3.0', 'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.', 'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -63,10 +63,6 @@ class FpQualityPoint(models.Model):
'fp.part.catalog', 'fp_quality_point_part_rel', 'fp.part.catalog', 'fp_quality_point_part_rel',
'point_id', 'part_id', string='Parts', '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( customer_spec_ids = fields.Many2many(
'fusion.plating.customer.spec', 'fusion.plating.customer.spec',
'fp_quality_point_spec_rel', 'fp_quality_point_spec_rel',
@@ -119,7 +115,7 @@ class FpQualityPoint(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Matching + spawning # Matching + spawning
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _matches(self, partner=None, part=None, coating=None, step=None, def _matches(self, partner=None, part=None, step=None,
customer_spec=None, recipe=None): customer_spec=None, recipe=None):
"""Return True if this point's filters all pass against the supplied """Return True if this point's filters all pass against the supplied
context. Empty filter == match anything. context. Empty filter == match anything.
@@ -130,9 +126,6 @@ class FpQualityPoint(models.Model):
if self.part_catalog_ids and ( if self.part_catalog_ids and (
not part or part not in self.part_catalog_ids): not part or part not in self.part_catalog_ids):
return False 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 ( if self.customer_spec_ids and (
not customer_spec not customer_spec
or customer_spec not in self.customer_spec_ids): or customer_spec not in self.customer_spec_ids):
@@ -146,7 +139,7 @@ class FpQualityPoint(models.Model):
return True return True
@api.model @api.model
def _find_matching(self, trigger, partner=None, part=None, coating=None, def _find_matching(self, trigger, partner=None, part=None,
step=None, customer_spec=None, recipe=None): step=None, customer_spec=None, recipe=None):
"""Return active points whose trigger + filters match the context.""" """Return active points whose trigger + filters match the context."""
candidates = self.search([ candidates = self.search([
@@ -154,7 +147,7 @@ class FpQualityPoint(models.Model):
('trigger_type', '=', trigger), ('trigger_type', '=', trigger),
]) ])
return candidates.filtered(lambda p: p._matches( return candidates.filtered(lambda p: p._matches(
partner=partner, part=part, coating=coating, step=step, partner=partner, part=part, step=step,
customer_spec=customer_spec, recipe=recipe, customer_spec=customer_spec, recipe=recipe,
)) ))

View File

@@ -52,22 +52,16 @@ class SaleOrderPointHook(models.Model):
# Walk lines for part / coating / spec context. # Walk lines for part / coating / spec context.
parts = so.order_line.mapped('x_fc_part_catalog_id') \ parts = so.order_line.mapped('x_fc_part_catalog_id') \
if 'x_fc_part_catalog_id' in so.order_line._fields else False if 'x_fc_part_catalog_id' in so.order_line._fields else False
coatings = so.order_line.mapped('x_fc_coating_config_id') \
if 'x_fc_coating_config_id' in so.order_line._fields else False
specs = so.order_line.mapped('x_fc_customer_spec_id') \ specs = so.order_line.mapped('x_fc_customer_spec_id') \
if 'x_fc_customer_spec_id' in so.order_line._fields else False if 'x_fc_customer_spec_id' in so.order_line._fields else False
points = Point._find_matching( points = Point._find_matching(
trigger='so_confirmed', partner=partner, trigger='so_confirmed', partner=partner,
) )
for point in points: for point in points:
# Filter by part / coating / spec intersection if the # Filter by part / spec intersection if the point cares.
# point cares.
if point.part_catalog_ids and parts and \ if point.part_catalog_ids and parts and \
not (point.part_catalog_ids & parts): not (point.part_catalog_ids & parts):
continue continue
if point.coating_config_ids and coatings and \
not (point.coating_config_ids & coatings):
continue
if point.customer_spec_ids and specs and \ if point.customer_spec_ids and specs and \
not (point.customer_spec_ids & specs): not (point.customer_spec_ids & specs):
continue continue
@@ -85,12 +79,11 @@ class FpJobPointHook(models.Model):
for job in self: for job in self:
partner = job.partner_id partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
customer_spec = getattr(job, 'customer_spec_id', False) or False customer_spec = getattr(job, 'customer_spec_id', False) or False
recipe = getattr(job, 'recipe_id', False) or False recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching( points = Point._find_matching(
trigger='job_confirmed', partner=partner, trigger='job_confirmed', partner=partner,
part=part or None, coating=coating or None, part=part or None,
customer_spec=customer_spec or None, customer_spec=customer_spec or None,
recipe=recipe or None, recipe=recipe or None,
) )
@@ -108,12 +101,11 @@ class FpJobPointHook(models.Model):
continue continue
partner = job.partner_id partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
customer_spec = getattr(job, 'customer_spec_id', False) or False customer_spec = getattr(job, 'customer_spec_id', False) or False
recipe = getattr(job, 'recipe_id', False) or False recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching( points = Point._find_matching(
trigger='job_done', partner=partner, trigger='job_done', partner=partner,
part=part or None, coating=coating or None, part=part or None,
customer_spec=customer_spec or None, customer_spec=customer_spec or None,
recipe=recipe or None, recipe=recipe or None,
) )
@@ -137,12 +129,11 @@ class FpJobStepPointHook(models.Model):
job = step.job_id job = step.job_id
partner = job.partner_id if job else False partner = job.partner_id if job else False
part = getattr(job, 'part_catalog_id', False) or False part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
customer_spec = getattr(job, 'customer_spec_id', False) or False customer_spec = getattr(job, 'customer_spec_id', False) or False
recipe = getattr(job, 'recipe_id', False) or False recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching( points = Point._find_matching(
trigger='job_step_done', partner=partner, trigger='job_step_done', partner=partner,
part=part or None, coating=coating or None, step=step, part=part or None, step=step,
customer_spec=customer_spec or None, customer_spec=customer_spec or None,
recipe=recipe or None, recipe=recipe or None,
) )

View File

@@ -24,19 +24,9 @@ class FpQuoteConfigurator(models.Model):
"""Extend the configurator's matcher to consider Spec + Recipe. """Extend the configurator's matcher to consider Spec + Recipe.
Spec match adds +8 (highest priority — explicit customer spec Spec match adds +8 (highest priority — explicit customer spec
wins over chemistry / cert-level filters). Recipe adds +6. wins over chemistry filters). Recipe adds +6. Material is +2.
Falls through to the existing coating / material / cert scoring.
""" """
# Cache the recipe before super (super may overwrite via thickness recipe = self.recipe_id or False
# logic in some inherit chains).
recipe = (
self.coating_config_id.recipe_id
if self.coating_config_id and self.coating_config_id.recipe_id
else False
)
# Build the candidate rule set the same way super does — but
# since super uses a private mechanism we re-implement to keep
# the spec/recipe scoring inline with the rest.
builder_rules = ( builder_rules = (
recipe.pricing_rule_ids recipe.pricing_rule_ids
if recipe else self.env['fp.pricing.rule'] if recipe else self.env['fp.pricing.rule']
@@ -49,38 +39,25 @@ class FpQuoteConfigurator(models.Model):
rules = self.env['fp.pricing.rule'].search( rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id' [('active', '=', True)], order='sequence, id'
) )
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None best = None
best_score = -1 best_score = -1
for rule in rules: for rule in rules:
score = 0 score = 0
# NEW — spec wins biggest # Spec wins biggest
if rule.customer_spec_id: if rule.customer_spec_id:
if rule.customer_spec_id != self.customer_spec_id: if rule.customer_spec_id != self.customer_spec_id:
continue continue
score += 8 score += 8
# NEW — recipe is next # Recipe is next
if rule.recipe_id: if rule.recipe_id:
if rule.recipe_id != recipe: if rule.recipe_id != recipe:
continue continue
score += 6 score += 6
# Legacy — coating / material / cert
if rule.coating_config_id:
if rule.coating_config_id != self.coating_config_id:
continue
score += 4
if rule.substrate_material: if rule.substrate_material:
if rule.substrate_material != self.substrate_material: if rule.substrate_material != self.substrate_material:
continue continue
score += 2 score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score: if score > best_score:
best_score = score best_score = score
best = rule best = rule

View File

@@ -15,17 +15,18 @@
<field name="inherit_id" <field name="inherit_id"
ref="fusion_plating_configurator.view_fp_direct_order_wizard_form"/> ref="fusion_plating_configurator.view_fp_direct_order_wizard_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Wizard line list (main editable rows) --> <!-- Wizard line list (main editable rows). Anchor on
<xpath expr="//field[@name='line_ids']/list/field[@name='coating_config_id']" internal_description (stable, configurator-defined). -->
<xpath expr="//field[@name='line_ids']/list/field[@name='internal_description']"
position="after"> position="after">
<field name="customer_spec_id" <field name="customer_spec_id"
string="Specification" string="Specification"
options="{'no_quick_create': True}" options="{'no_quick_create': True}"
optional="show"/> optional="show"/>
</xpath> </xpath>
<!-- Wizard line drawer / form view (the "expand line" panel) --> <!-- Wizard line drawer / form view -->
<xpath expr="//field[@name='line_ids']/form//field[@name='coating_config_id']" <xpath expr="//field[@name='line_ids']/form//field[@name='process_variant_id']"
position="after"> position="before">
<field name="customer_spec_id" <field name="customer_spec_id"
string="Specification" string="Specification"
options="{'no_quick_create': True}"/> options="{'no_quick_create': True}"/>

View File

@@ -16,7 +16,9 @@
<field name="inherit_id" <field name="inherit_id"
ref="fusion_plating_configurator.view_fp_part_catalog_form"/> ref="fusion_plating_configurator.view_fp_part_catalog_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='x_fc_default_coating_config_id']" <!-- Anchor on default_process_id (stable, in core).
Default Treatment block was removed in Phase E. -->
<xpath expr="//field[@name='default_process_id']"
position="after"> position="after">
<field name="x_fc_default_customer_spec_id" <field name="x_fc_default_customer_spec_id"
string="Default Specification" string="Default Specification"

View File

@@ -16,8 +16,8 @@
<field name="inherit_id" <field name="inherit_id"
ref="fusion_plating_configurator.view_fp_pricing_rule_form"/> ref="fusion_plating_configurator.view_fp_pricing_rule_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='coating_config_id']" <xpath expr="//field[@name='substrate_material']"
position="after"> position="before">
<field name="customer_spec_id" <field name="customer_spec_id"
options="{'no_quick_create': True}"/> options="{'no_quick_create': True}"/>
<field name="recipe_id" <field name="recipe_id"

View File

@@ -69,8 +69,6 @@
placeholder="All specs if empty"/> placeholder="All specs if empty"/>
<field name="recipe_ids" widget="many2many_tags" <field name="recipe_ids" widget="many2many_tags"
placeholder="All recipes if empty"/> placeholder="All recipes if empty"/>
<field name="coating_config_ids" widget="many2many_tags"
placeholder="All coatings if empty"/>
<field name="step_kind" <field name="step_kind"
invisible="trigger_type != 'job_step_done'" invisible="trigger_type != 'job_step_done'"
placeholder="Any step kind if empty"/> placeholder="Any step kind if empty"/>

View File

@@ -17,15 +17,18 @@
<!-- Configurator's view_sale_order_form_fp inherits sale.view_order_form <!-- Configurator's view_sale_order_form_fp inherits sale.view_order_form
and adds Plating fields to the order_line tree. We inherit THAT and adds Plating fields to the order_line tree. We inherit THAT
view to add Specification right after Primary Treatment. --> view to add Specification next to Part Catalog. -->
<record id="view_sale_order_form_quality_inherit" model="ir.ui.view"> <record id="view_sale_order_form_quality_inherit" model="ir.ui.view">
<field name="name">sale.order.form.quality.spec.inherit</field> <field name="name">sale.order.form.quality.spec.inherit</field>
<field name="model">sale.order</field> <field name="model">sale.order</field>
<field name="inherit_id" <field name="inherit_id"
ref="fusion_plating_configurator.view_sale_order_form_fp"/> ref="fusion_plating_configurator.view_sale_order_form_fp"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Editable order_line tree (estimator's main grid) --> <!-- Editable order_line tree (estimator's main grid).
<xpath expr="//field[@name='order_line']/list/field[@name='x_fc_coating_config_id']" Anchor on x_fc_internal_description because it's
unique to the editable list (not in the read-only
summary list at the form bottom). -->
<xpath expr="//field[@name='x_fc_internal_description']"
position="after"> position="after">
<field name="x_fc_customer_spec_id" <field name="x_fc_customer_spec_id"
string="Specification" string="Specification"

View File

@@ -115,9 +115,6 @@
<t t-if="so and so.x_fc_customer_spec_id"> <t t-if="so and so.x_fc_customer_spec_id">
<span t-field="so.x_fc_customer_spec_id"/> <span t-field="so.x_fc_customer_spec_id"/>
</t> </t>
<t t-elif="so and so.x_fc_coating_config_id">
<span t-field="so.x_fc_coating_config_id"/>
</t>
<t t-else=""></t> <t t-else=""></t>
</td> </td>
<th class="info-header">Recipe</th> <th class="info-header">Recipe</th>

View File

@@ -19,8 +19,7 @@
* _mo — the mrp.production record (or False) * _mo — the mrp.production record (or False)
* _so, _line — the originating sale order / line * _so, _line — the originating sale order / line
* _part — fp.part.catalog * _part — fp.part.catalog
* _coating — fp.coating.config (legacy; removed in Phase E) * _spec — fusion.plating.customer.spec (audit-tracked spec)
* _spec — fusion.plating.customer.spec (the audit-tracked spec the cert prints)
* _process — the resolved fusion.plating.process.node tree * _process — the resolved fusion.plating.process.node tree
* _due — datetime/date for "Due Date" row * _due — datetime/date for "Due Date" row
* _qty — float for "Qty" row * _qty — float for "Qty" row
@@ -48,11 +47,9 @@
or (_so and _so.order_line[:1]) or (_so and _so.order_line[:1])
or False"/> or False"/>
<t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/> <t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/>
<t t-set="_coating" t-value="_coating or (_line and _line.x_fc_coating_config_id) or False"/>
<t t-set="_spec" t-value="_spec or (_line and _line.x_fc_customer_spec_id) or False"/> <t t-set="_spec" t-value="_spec or (_line and _line.x_fc_customer_spec_id) or False"/>
<t t-set="_process" t-value="_process <t t-set="_process" t-value="_process
or (_part and _part.default_process_id) or (_part and _part.default_process_id)
or (_coating and _coating.recipe_id)
or False"/> or False"/>
<t t-set="_due" t-value="_due <t t-set="_due" t-value="_due
or (_mo and (_mo.date_deadline or _mo.date_finished)) or (_mo and (_mo.date_deadline or _mo.date_finished))
@@ -470,7 +467,6 @@
<t t-set="_so" t-value="so"/> <t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/> <t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/> <t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/> <t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/> <t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/> <t t-set="_qty" t-value="line.product_uom_qty"/>
@@ -501,7 +497,6 @@
<t t-set="_so" t-value="so"/> <t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/> <t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/> <t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/> <t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/> <t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/> <t t-set="_qty" t-value="line.product_uom_qty"/>

View File

@@ -1142,7 +1142,7 @@ class FpShopfloorController(http.Controller):
job_read_fields = [ job_read_fields = [
'name', 'origin', 'priority', 'partner_id', 'product_id', 'name', 'origin', 'priority', 'partner_id', 'product_id',
'qty', 'qty_done', 'date_planned_start', 'date_deadline', 'qty', 'qty_done', 'date_planned_start', 'date_deadline',
'part_catalog_id', 'coating_config_id', 'part_catalog_id',
] ]
if 'customer_spec_id' in unique_jobs._fields: if 'customer_spec_id' in unique_jobs._fields:
job_read_fields.append('customer_spec_id') job_read_fields.append('customer_spec_id')
@@ -1555,10 +1555,6 @@ class FpShopfloorController(http.Controller):
job.part_catalog_id job.part_catalog_id
if 'part_catalog_id' in job._fields else False if 'part_catalog_id' in job._fields else False
) )
coating = (
job.coating_config_id
if 'coating_config_id' in job._fields else False
)
# Specification (added by fusion_plating_quality) # Specification (added by fusion_plating_quality)
spec = ( spec = (
job.customer_spec_id job.customer_spec_id
@@ -1572,12 +1568,9 @@ class FpShopfloorController(http.Controller):
getattr(part, 'part_number', '') or part.name or '' getattr(part, 'part_number', '') or part.name or ''
) )
part_revision = getattr(part, 'revision', '') or '' part_revision = getattr(part, 'revision', '') or ''
# coating_label kept blank — Phase E removed coating; downstream
# tablet templates read spec_label instead.
coating_label = '' coating_label = ''
if coating:
spec_ref = getattr(coating, 'spec_reference', '') or ''
coating_label = (
f'{coating.name} · {spec_ref}' if spec_ref else coating.name
)
# Customer logo + product image # Customer logo + product image
customer_logo_url = '' customer_logo_url = ''