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:
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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]
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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
|
||||
),
|
||||
})],
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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 '
|
||||
|
||||
Reference in New Issue
Block a user