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',
'version': '19.0.5.6.0',
'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -286,14 +286,27 @@ class FpCertificate(models.Model):
def create(self, vals_list):
SaleOrder = self.env['sale.order']
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')
if not already_set and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id'])
cfg = getattr(so, 'x_fc_coating_config_id', False)
if cfg and cfg.thickness_uom == 'mils':
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
# Look across order_line for the first recipe with a
# populated thickness range.
first_line = so.order_line[:1] if so.order_line else False
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
# name via raw SQL, then fall back to the legacy sequence if
# no parent SO is reachable.

View File

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.19.0.0',
'version': '19.0.20.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -39,16 +39,11 @@ Provides:
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'data/fp_sub5_sequence_data.xml',
'data/fp_treatment_data.xml',
'data/fp_part_material_data.xml',
'views/fp_treatment_views.xml',
'views/fp_part_material_views.xml',
'views/fp_coating_thickness_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_process_node_part_scoped_views.xml',
'views/fp_coating_config_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_customer_price_list_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_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)
# Part of the Fusion Plating product family.
from . import fp_treatment
from . import fp_part_material
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_rule
from . import fp_customer_price_list
from . import fp_sale_description_template
from . import fp_quote_configurator
from . import fp_serial

View File

@@ -70,8 +70,7 @@ class AccountMoveLine(models.Model):
string='Thickness',
help='Copied from sale.order.line for customer-facing invoice PDFs.',
)
# x_fc_customer_spec_id is added by fusion_plating_quality (where
# fusion.plating.customer.spec lives).
# x_fc_customer_spec_id added by fusion_plating_quality.
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
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)
# ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one(
'fp.coating.config',
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.',
)
# x_fc_default_customer_spec_id added by fusion_plating_quality.
# Legacy default_coating_config_id + default_treatment_ids removed.
# Substrate density mapping (g/cm³) for material weight calculation
_SUBSTRATE_DENSITY = {

View File

@@ -18,8 +18,9 @@ class FpPricingRule(models.Model):
_order = 'sequence, id'
name = fields.Char(string='Rule Name', required=True)
coating_config_id = fields.Many2one('fp.coating.config', string='Coating Config',
help='Leave blank for a global rule.')
# coating_config_id removed. Spec + recipe match keys live on
# fusion_plating_quality.fp_pricing_rule_inherit. Material +
# cert_level (below) remain as generic filters.
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('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_filename = fields.Char(string='PO Filename')
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating Configuration', required=True,
# Renamed from coating_config_id (Phase E — Promote Customer Spec).
# 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)
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)
self.masking_area_sqin = cat.masking_area_sqin
@api.onchange('coating_config_id')
def _onchange_coating_config_id(self):
if self.coating_config_id:
self.thickness_requested = self.coating_config_id.thickness_min
@api.onchange('recipe_id')
def _onchange_recipe_id(self):
if self.recipe_id and self.recipe_id.thickness_min:
self.thickness_requested = self.recipe_id.thickness_min
# -------------------------------------------------------------------------
# Price calculation
@@ -358,11 +365,11 @@ class FpQuoteConfigurator(models.Model):
'masking_zones', 'complexity', 'substrate_material',
'quantity', 'batch_size', 'rush_order',
'shipping_fee', 'delivery_fee',
'coating_config_id', 'coating_config_id.certification_level',
'recipe_id',
)
def _compute_price(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.price_breakdown_html = ''
continue
@@ -476,19 +483,17 @@ class FpQuoteConfigurator(models.Model):
def _find_matching_rule(self):
"""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.
When the chosen coating config points at a recipe and that recipe
has `pricing_rule_ids` configured, the search is constrained to
those rules ("Use Price Builders" semantics). Otherwise the
whole active rule set is considered as before.
When the chosen recipe has `pricing_rule_ids` configured, the
search is constrained to those rules ("Use Price Builders"
semantics). Otherwise the whole active rule set is considered.
Spec-tier scoring is added by an inherit in
fusion_plating_quality (where customer.spec lives).
"""
recipe = (
self.coating_config_id.recipe_id
if self.coating_config_id and self.coating_config_id.recipe_id
else False
)
recipe = self.recipe_id or False
builder_rules = (
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(
[('active', '=', True)], order='sequence, id'
)
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None
best_score = -1
for rule in rules:
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 != self.substrate_material:
continue
score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score:
best_score = score
best = rule
@@ -569,9 +562,9 @@ class FpQuoteConfigurator(models.Model):
raise UserError(_(
'Pick a part catalog entry before promoting this quote.'
))
if not self.coating_config_id:
if not self.recipe_id:
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([
('quote_id', '=', self.id),
@@ -618,14 +611,13 @@ class FpQuoteConfigurator(models.Model):
'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'
so_vals = {
'partner_id': self.partner_id.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_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
# Transfer RFQ / PO documents from configurator (if any)
@@ -641,17 +633,19 @@ class FpQuoteConfigurator(models.Model):
'origin': self.name,
'order_line': [(0, 0, {
'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,
'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
# 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': (
self.part_catalog_id.id if self.part_catalog_id else False
),
'x_fc_coating_config_id': (
self.coating_config_id.id if self.coating_config_id else False
'x_fc_process_variant_id': (
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 '
'the order. Leave blank for generic fallback templates.',
)
# Related fields — surface the part's partner/coating for search &
# grouping without writing them twice.
# Related fields — surface the part's partner for search & grouping
# without writing it twice.
partner_id = fields.Many2one(
'res.partner', string='Customer',
related='part_catalog_id.partner_id', store=True, readonly=True,
)
# Keep the explicit coating slot for global templates that aren't
# part-specific but are still coating-specific.
coating_config_id = fields.Many2one(
'fp.coating.config', string='Associated Coating',
ondelete='set null',
help='For generic (no-part) templates, restrict to one coating.',
)
# coating_config_id removed; templates can be customer- or part-
# scoped. Spec-scoped templates are a future enhancement.
tag = fields.Selection(
[('standard', 'Standard'),
('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_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_attachment_id = fields.Many2one(
'ir.attachment', string='PO Document', tracking=True,
@@ -209,7 +210,7 @@ class SaleOrder(models.Model):
for so in self:
variants = []
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
variant = (line.x_fc_process_variant_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')
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
widget='percentage' formats 100% as 100%, not 10000%.
x_fc_margin_available is False when NO line has a costed coating
(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%.
Pre-promote-customer-spec, this rolled up cost from
fp.coating.config.unit_cost. Coating Config is retired; cost
data on the recipe is a future enhancement (backlog). Until
then, margin is "not available" and the UI hides the fields.
"""
for rec in self:
has_cost_data = False
cost = 0.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_available = False
rec.x_fc_margin_amount = (rec.amount_untaxed or 0)
rec.x_fc_margin_percent = (
(rec.x_fc_margin_amount / rec.amount_untaxed)
if (rec.amount_untaxed and has_cost_data) else 0.0

View File

@@ -59,15 +59,9 @@ class SaleOrderLine(models.Model):
string='Description Template',
help='Which template row populated this line. Informational.',
)
x_fc_coating_config_id = fields.Many2one(
'fp.coating.config', string='Primary Treatment',
)
# 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',
)
# Specification picker (x_fc_customer_spec_id) is added by
# fusion_plating_quality. Legacy x_fc_coating_config_id +
# x_fc_treatment_ids removed.
x_fc_part_deadline = fields.Date(
string='Part Deadline Override',
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
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_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_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_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
@@ -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_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_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_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
@@ -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_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_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_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
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"
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"
name="Pricing Rules"
parent="menu_fp_configurator"
action="action_fp_pricing_rule"
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"
name="Materials"
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"/>
</list>
</field>
<separator string="Default Treatments" class="mt-4"/>
<group>
<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>
<!-- Default Specification picker added by
fusion_plating_quality view inherit. -->
<p class="text-muted">
Seeds the treatment fields on new direct-order
lines for this part. Updated whenever "Save as
Default" is ticked while placing an order.
Set a Default Specification on this part
(under the section added by the Quality
module) so future direct-order lines
pre-fill it automatically.
</p>
</page>
<page string="Dimensions &amp; Complexity" name="dimensions">

View File

@@ -14,7 +14,6 @@
<list string="Pricing Rules" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
<field name="pricing_method"/>
@@ -42,7 +41,6 @@
</div>
<group string="Filters">
<group>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
</group>
@@ -104,7 +102,6 @@
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="coating_config_id"/>
<separator/>
<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')]"/>
@@ -113,7 +110,6 @@
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<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'}"/>
</group>
</search>

View File

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

View File

@@ -22,7 +22,6 @@
decoration-danger="tag == 'rework'"
decoration-success="tag in ('aerospace','nuclear')"/>
<field name="partner_id" optional="show"/>
<field name="coating_config_id" optional="hide"/>
<field name="usage_count" string="Used"/>
<field name="active" widget="boolean_toggle"/>
</list>
@@ -46,9 +45,6 @@
<field name="tag"/>
</group>
<group>
<field name="coating_config_id"
help="Only used for generic (no-part) templates."
invisible="part_catalog_id"/>
<field name="sequence"/>
<field name="usage_count" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
@@ -75,7 +71,6 @@
<field name="internal_description"/>
<field name="customer_facing_description"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="partner_id"/>
<field name="tag"/>
<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
without scrolling pricing columns. The pre-Sub-12 SO-
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
silent on direct orders, which is why they appeared
empty after confirm. They still exist on the model
@@ -118,7 +118,6 @@
readonly="1">
<list create="false" delete="false" edit="false">
<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_process_variant_id" optional="show"
string="Process"/>
@@ -251,7 +250,6 @@
<field name="x_fc_internal_description"
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id"
string="Process / Recipe"
options="{'no_quick_create': True}"
@@ -290,7 +288,6 @@
<field name="x_fc_revision_snapshot"
readonly="1"
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_offset_days" string="Days Offset" optional="hide"/>
<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_planned_start_date" 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="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
widget="monetary"
@@ -363,7 +359,6 @@
<field name="arch" type="xml">
<kanban default_group_by="x_fc_part_catalog_id" records_draggable="0">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<field name="qty_delivered"/>
<field name="x_fc_wo_group_tag"/>
@@ -373,7 +368,7 @@
<t t-name="card">
<div class="o_kanban_card_content">
<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 class="text-muted">
Qty: <field name="product_uom_qty"/>
@@ -399,7 +394,6 @@
<kanban default_group_by="x_fc_wo_group_tag" records_draggable="0">
<field name="x_fc_wo_group_tag"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<templates>
<t t-name="card">
@@ -407,9 +401,6 @@
<div>
<strong><field name="x_fc_part_catalog_id"/></strong>
</div>
<div class="text-muted">
<field name="x_fc_coating_config_id"/>
</div>
<div>
Qty: <field name="product_uom_qty"/>
</div>

View File

@@ -43,14 +43,14 @@ class FpAddFromQuoteWizard(models.TransientModel):
wizard = self.direct_order_wizard_id
copied = 0
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
Line._create_from_quote(q, wizard)
copied += 1
if not copied:
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.'
))

View File

@@ -22,7 +22,7 @@
<list>
<field name="name"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="recipe_id"/>
<field name="quantity"/>
<field name="calculated_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
copied = 0
for src in self.source_line_ids:
if not src.x_fc_part_catalog_id or not src.x_fc_coating_config_id:
# Skip SO lines that predate the plating fields
if not src.x_fc_part_catalog_id:
# Skip non-plating SO lines
continue
Line.create({
'wizard_id': wizard.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,
'unit_price': src.price_unit or 0.0,
'part_deadline': src.x_fc_part_deadline,

View File

@@ -27,7 +27,7 @@
<list>
<field name="name"/>
<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="price_unit"/>
<field name="x_fc_part_deadline"/>

View File

@@ -51,22 +51,9 @@ class FpDirectOrderLine(models.Model):
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(string='Revision Note')
# ---- Treatments ----
coating_config_id = fields.Many2one(
'fp.coating.config',
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.',
)
# Specification picker (customer_spec_id) added by
# fusion_plating_quality. Legacy coating_config_id +
# treatment_ids removed.
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
@@ -107,8 +94,7 @@ class FpDirectOrderLine(models.Model):
)
@api.depends('process_variant_id',
'part_catalog_id.default_process_id',
'coating_config_id.recipe_id')
'part_catalog_id.default_process_id')
def _compute_effective_process(self):
for rec in self:
if rec.process_variant_id:
@@ -122,12 +108,6 @@ class FpDirectOrderLine(models.Model):
rec.effective_process_id = part_proc
rec.effective_process_source = 'Part default'
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_source = False
@@ -168,35 +148,26 @@ class FpDirectOrderLine(models.Model):
if not rec.part_catalog_id:
continue
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
# 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
# push_to_defaults toggle so whatever Sarah picks becomes
# the saved default — surface a warning popup so she knows.
# `is_one_off` always wins (operator opted out of catalog
# persistence), so don't auto-tick in that case.
if (not has_default_coating
and not has_default_treatments
if (not has_default_spec
and not rec.is_one_off
and not rec.push_to_defaults):
rec.push_to_defaults = True
warning = {
'title': _('First-Time Part — Defaults Will Be Saved'),
'message': _(
'%(part)s has no saved coating / treatments. '
'The coating + treatments you pick on this line '
'will be saved as the part\'s defaults so the '
'%(part)s has no saved specification. '
'The specification you pick on this line will '
'be saved as the part\'s default so the '
'next order auto-fills them. Untick "Save as '
'Default" on the line if you don\'t want this.'
) % {'part': part.display_name or part.part_number or '(part)'},
@@ -269,11 +240,11 @@ class FpDirectOrderLine(models.Model):
start_at_node_id = fields.Many2one(
'fusion.plating.process.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 '
'begin. Pick a coating first — nodes are scoped to its '
'recipe tree. Skips earlier steps in the generated WO but '
'keeps later siblings and sub-processes.',
'begin. Pick a recipe first — nodes are scoped to it. Skips '
'earlier steps in the generated WO but keeps later siblings '
'and sub-processes.',
)
is_one_off = fields.Boolean(
string='One-off Part',
@@ -436,12 +407,11 @@ class FpDirectOrderLine(models.Model):
for rec in self:
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):
for rec in self:
rec.is_missing_info = not (
rec.part_catalog_id
and rec.coating_config_id
and rec.unit_price
and rec.quantity
)
@@ -499,14 +469,16 @@ class FpDirectOrderLine(models.Model):
# ---- Onchange ----
@api.onchange('quote_id')
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:
return
q = self.quote_id
if q.part_catalog_id and not self.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:
final = q.estimator_override_price or q.calculated_price
if final and q.quantity:
@@ -514,13 +486,13 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
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:
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
# mapped from the customer. Only fills when the user hasn't set
# taxes manually.
@@ -543,21 +515,10 @@ class FpDirectOrderLine(models.Model):
if taxes:
self.tax_ids = [(6, 0, taxes.ids)]
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
def _onchange_lookup_price(self):
"""Auto-fill unit_price from customer price list when available."""
if self.unit_price:
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
# Auto-fill unit_price from a customer price list — extended in
# fusion_plating_quality (the spec field lives there). The base
# configurator wizard no longer triggers price lookup since
# coating_config_id is gone.
@api.onchange('description_template_id')
def _onchange_description_template(self):
@@ -575,15 +536,14 @@ class FpDirectOrderLine(models.Model):
if 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):
"""Offer a sensible default template — part-specific wins.
Priority (first non-empty result wins):
1. This part's lowest-sequence active template
2. This customer's templates (no part)
3. This coating's templates (no part)
4. Don't auto-pick — user has to choose
3. Don't auto-pick — user has to choose
"""
if self.description_template_id or self.line_description:
return
@@ -616,16 +576,6 @@ class FpDirectOrderLine(models.Model):
_apply(match)
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 ----
@api.model
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
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(_(
'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))
final = quote.estimator_override_price or quote.calculated_price
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({
'wizard_id': wizard.id,
'part_catalog_id': quote.part_catalog_id.id,
'coating_config_id': quote.coating_config_id.id,
'quantity': int(quote.quantity) or 1,
'unit_price': unit,
'quote_id': quote.id,

View File

@@ -550,12 +550,13 @@ class FpDirectOrderWizard(models.Model):
for line in self.line_ids:
part = line._get_or_bump_revision()
resolved_parts[line.id] = part
# Build the line header. Primary treatment is optional now;
# when missing, drop it from the header rather than printing
# Build the line header. Specification is optional; when
# missing, drop it from the header rather than printing
# "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)' % (
treatment_label,
spec_label,
part.name,
part.revision,
line.quantity,
@@ -573,10 +574,9 @@ class FpDirectOrderWizard(models.Model):
'x_fc_part_catalog_id': part.id,
'x_fc_description_template_id': line.description_template_id.id or False,
'x_fc_internal_description': line.internal_description or False,
'x_fc_coating_config_id': line.coating_config_id.id,
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
# x_fc_customer_spec_id is added to vals by an extension
# of this method in fusion_plating_quality.
# x_fc_customer_spec_id is set on the resulting SO line
# by an extension in fusion_plating_quality (post-create
# patch — see fp_direct_order_line_inherit.py).
'x_fc_part_deadline': line.part_deadline,
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
'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.'
) % {'doo': self.name, 'so': so.name})
# 6. Push-to-defaults (C4) — uses the resolved part cached
# during the build loop so rev-bumped lines write defaults to
# the NEW revision, not the pre-bump one.
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)],
})
# 6. Push-to-defaults — Specification carry-over to the part's
# x_fc_default_customer_spec_id is handled by an inherit in
# fusion_plating_quality (the field lives there).
so.message_post(body=_(
'Quotation created from PO %s with %d line(s). '
'Review and confirm manually when ready.'

View File

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

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.9.1.0',
'version': '19.0.10.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'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_batch', # fusion.plating.batch (Phase 3)
'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_logistics', # fusion.plating.delivery
'fusion_plating_notifications', # fp.notification.template (Phase 4)

View File

@@ -48,15 +48,12 @@ class FpJob(models.Model):
string='Part',
ondelete='restrict',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
ondelete='restrict',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
string='Specification',
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(
'fusion.plating.portal.job',
@@ -996,29 +993,28 @@ class FpJob(models.Model):
if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when
# this is a plating step (matched by node name keyword).
coating = job.coating_config_id
# Pull thickness target from the recipe root when this
# is a plating step (matched by node name keyword).
# Recipe-root carries thickness fields post-promote-spec.
recipe_root = job.recipe_id
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' 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 (
'thickness_max' in coating._fields
and coating.thickness_max
'thickness_max' in recipe_root._fields
and recipe_root.thickness_max
):
vals['thickness_target'] = coating.thickness_max
vals['thickness_target'] = recipe_root.thickness_max
if (
'thickness_uom' in coating._fields
and coating.thickness_uom
'thickness_uom' in recipe_root._fields
and recipe_root.thickness_uom
):
# fp.coating.config uses long-form uom names
# (mils / microns / inches); fp.job.step uses
# short codes (mil / um / inch). Map between
# them. Unknown values fall through to the
# step's default ('um').
# Recipe uses long-form uom names (mils /
# microns / inches); fp.job.step uses short
# codes (mil / um / inch). Map between them.
_UOM_MAP = {
'mils': 'mil',
'mil': 'mil',
@@ -1029,7 +1025,7 @@ class FpJob(models.Model):
'inch': 'inch',
'in': 'inch',
}
mapped = _UOM_MAP.get(coating.thickness_uom)
mapped = _UOM_MAP.get(recipe_root.thickness_uom)
if mapped:
vals['thickness_uom'] = mapped
@@ -1546,7 +1542,9 @@ class FpJob(models.Model):
if not required:
return
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):
# Idempotency per 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:
vals['sale_order_id'] = self.sale_order_id.id
# spec_reference is what action_issue blocks on.
if coating and 'spec_reference' in Cert._fields \
and getattr(coating, 'spec_reference', False):
vals['spec_reference'] = coating.spec_reference
# Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields:
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:
vals['part_number'] = (
self.part_catalog_id.part_number or ''

View File

@@ -474,8 +474,9 @@ class FpJobStep(models.Model):
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes
on a coating that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance);
on a recipe that requires hydrogen-embrittlement relief
(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×
duration_expected — silent overruns are a red flag for
scheduling and costing.
@@ -499,12 +500,11 @@ class FpJobStep(models.Model):
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
coating = step.job_id.coating_config_id \
if 'coating_config_id' in step.job_id._fields else False
if not coating:
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(coating, 'requires_bake_relief', False)
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
# 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
part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe; must beat any
generic coating template.
3. coating.recipe_id — coating-config recipe
(generic template fallback).
4. part.recipe_id — legacy fallback.
variant. Customer-and-part-tuned recipe.
3. part.recipe_id — legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
@@ -352,11 +349,6 @@ class SaleOrder(models.Model):
) or False
if not part and 'x_fc_part_catalog_id' in self._fields:
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 = (
'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id
@@ -365,8 +357,6 @@ class SaleOrder(models.Model):
return picked
if part and 'default_process_id' in part._fields and 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:
return part.recipe_id
return Node
@@ -389,22 +379,22 @@ class SaleOrder(models.Model):
if existing:
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(
lambda l: (
('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
# header but not on the line. Treat the entire order as one
# plating line so the planner gets an fp.job to work against.
# Fallback: SOs that carry part on the header but not on the
# line. Treat the entire order as one plating job so the planner
# gets an fp.job to work against.
if not plating_lines and self.order_line and (
('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)
'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
):
_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,
)
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)
return
# Group by (recipe, part, coating, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning
# as part_id + coating_id: bundling lines with different thicknesses
# or different serials under one WO would carry the first line's
# values onto the cert + sticker — silent mis-attestation. Sub 5
# added thickness_id + serial_id; this extends the grouping logic
# to honour them. No-recipe lines still get their own group each.
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker —
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
@@ -427,9 +416,9 @@ class SaleOrder(models.Model):
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
coating_id = (
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_id = (
'x_fc_thickness_id' in line._fields
@@ -440,7 +429,7 @@ class SaleOrder(models.Model):
and line.x_fc_serial_id.id
) or False
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:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
@@ -465,11 +454,6 @@ class SaleOrder(models.Model):
and first_line.x_fc_part_catalog_id
or False
)
coating = (
'x_fc_coating_config_id' in first_line._fields
and first_line.x_fc_coating_config_id
or False
)
customer_spec = (
'x_fc_customer_spec_id' in first_line._fields
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:
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)
vals = {
@@ -492,8 +474,6 @@ class SaleOrder(models.Model):
}
if part:
vals['part_catalog_id'] = part.id
if coating:
vals['coating_config_id'] = coating.id
if customer_spec:
vals['customer_spec_id'] = customer_spec.id
if recipe:

View File

@@ -56,7 +56,6 @@
<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="_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="_process" t-value="job.recipe_id 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="_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="_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="_process" t-value="job.recipe_id 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">
<span t-esc="job.customer_spec_id.display_name"/>
</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>
</tr>
</table>

View File

@@ -95,7 +95,7 @@
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<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"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.5.3.0',
'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'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',
'point_id', 'part_id', string='Parts',
)
coating_config_ids = fields.Many2many(
'fp.coating.config', 'fp_quality_point_coating_rel',
'point_id', 'coating_id', string='Coatings',
)
customer_spec_ids = fields.Many2many(
'fusion.plating.customer.spec',
'fp_quality_point_spec_rel',
@@ -119,7 +115,7 @@ class FpQualityPoint(models.Model):
# ------------------------------------------------------------------
# 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):
"""Return True if this point's filters all pass against the supplied
context. Empty filter == match anything.
@@ -130,9 +126,6 @@ class FpQualityPoint(models.Model):
if self.part_catalog_ids and (
not part or part not in self.part_catalog_ids):
return False
if self.coating_config_ids and (
not coating or coating not in self.coating_config_ids):
return False
if self.customer_spec_ids and (
not customer_spec
or customer_spec not in self.customer_spec_ids):
@@ -146,7 +139,7 @@ class FpQualityPoint(models.Model):
return True
@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):
"""Return active points whose trigger + filters match the context."""
candidates = self.search([
@@ -154,7 +147,7 @@ class FpQualityPoint(models.Model):
('trigger_type', '=', trigger),
])
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,
))

View File

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

View File

@@ -24,19 +24,9 @@ class FpQuoteConfigurator(models.Model):
"""Extend the configurator's matcher to consider Spec + Recipe.
Spec match adds +8 (highest priority — explicit customer spec
wins over chemistry / cert-level filters). Recipe adds +6.
Falls through to the existing coating / material / cert scoring.
wins over chemistry filters). Recipe adds +6. Material is +2.
"""
# Cache the recipe before super (super may overwrite via thickness
# 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.
recipe = self.recipe_id or False
builder_rules = (
recipe.pricing_rule_ids
if recipe else self.env['fp.pricing.rule']
@@ -49,38 +39,25 @@ class FpQuoteConfigurator(models.Model):
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None
best_score = -1
for rule in rules:
score = 0
# NEW — spec wins biggest
# Spec wins biggest
if rule.customer_spec_id:
if rule.customer_spec_id != self.customer_spec_id:
continue
score += 8
# NEW — recipe is next
# Recipe is next
if rule.recipe_id:
if rule.recipe_id != recipe:
continue
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 != self.substrate_material:
continue
score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score:
best_score = score
best = rule

View File

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

View File

@@ -16,7 +16,9 @@
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_part_catalog_form"/>
<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">
<field name="x_fc_default_customer_spec_id"
string="Default Specification"

View File

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

View File

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

View File

@@ -17,15 +17,18 @@
<!-- Configurator's view_sale_order_form_fp inherits sale.view_order_form
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">
<field name="name">sale.order.form.quality.spec.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_sale_order_form_fp"/>
<field name="arch" type="xml">
<!-- Editable order_line tree (estimator's main grid) -->
<xpath expr="//field[@name='order_line']/list/field[@name='x_fc_coating_config_id']"
<!-- Editable order_line tree (estimator's main grid).
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">
<field name="x_fc_customer_spec_id"
string="Specification"

View File

@@ -115,9 +115,6 @@
<t t-if="so and so.x_fc_customer_spec_id">
<span t-field="so.x_fc_customer_spec_id"/>
</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>
</td>
<th class="info-header">Recipe</th>

View File

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

View File

@@ -1142,7 +1142,7 @@ class FpShopfloorController(http.Controller):
job_read_fields = [
'name', 'origin', 'priority', 'partner_id', 'product_id',
'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:
job_read_fields.append('customer_spec_id')
@@ -1555,10 +1555,6 @@ class FpShopfloorController(http.Controller):
job.part_catalog_id
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)
spec = (
job.customer_spec_id
@@ -1572,12 +1568,9 @@ class FpShopfloorController(http.Controller):
getattr(part, 'part_number', '') or part.name or ''
)
part_revision = getattr(part, 'revision', '') or ''
# coating_label kept blank — Phase E removed coating; downstream
# tablet templates read spec_label instead.
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_url = ''