61 KiB
Phase 1: Configurator & Sales Integration — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the fusion_plating_configurator module with part catalog, coating configs, pricing engine, and sale order integration — making the Fusion Plating app the single hub for the plating business with Sales as the default landing page.
Architecture: New Odoo 19 module (fusion_plating_configurator) with 7 models, extending sale.order with plating-specific fields. Role-based security groups layered on the existing 4-level privilege hierarchy. Menu restructured so Sales is the default view. 3D viewer and portal are deferred to Phase 1B/1C.
Tech Stack: Odoo 19, Python 3.12, PostgreSQL, OWL (for future 3D viewer), SCSS
Spec: docs/superpowers/specs/2026-04-12-entech-plating-workflow-design.md
Existing patterns: Follow fusion_plating core module conventions (see fp_process_type.py, fp_security.xml, fp_menu.xml).
File Structure
fusion_plating_configurator/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ ├── fp_treatment.py # Pre/post treatment (simplest model)
│ ├── fp_part_catalog.py # Customer part library
│ ├── fp_coating_config.py # Coating configuration templates
│ ├── fp_pricing_rule.py # Pricing engine rules
│ ├── fp_pricing_complexity_surcharge.py # Complexity surcharge lines
│ ├── fp_quote_configurator.py # Configurator session + price calc
│ └── sale_order.py # sale.order extensions (x_fc_* fields)
├── security/
│ ├── fp_configurator_security.xml # Role-based groups
│ └── ir.model.access.csv # Model-level ACL
├── views/
│ ├── fp_treatment_views.xml
│ ├── fp_part_catalog_views.xml
│ ├── fp_coating_config_views.xml
│ ├── fp_pricing_rule_views.xml
│ ├── fp_quote_configurator_views.xml
│ ├── sale_order_views.xml # Custom plating SO views
│ └── fp_configurator_menu.xml # Menus under Fusion Plating app
├── data/
│ └── fp_configurator_sequence_data.xml # Sequences (CFG-XXXXX)
└── static/
└── description/
└── icon.png # Module icon (copy from core)
Existing files modified:
fusion_plating/views/fp_menu.xml— restructure to add Sales submenu as default
Task 1: Module Scaffold
Files:
-
Create:
fusion_plating_configurator/__init__.py -
Create:
fusion_plating_configurator/__manifest__.py -
Create:
fusion_plating_configurator/models/__init__.py -
Create:
fusion_plating_configurator/security/fp_configurator_security.xml -
Create:
fusion_plating_configurator/security/ir.model.access.csv -
Create:
fusion_plating_configurator/data/fp_configurator_sequence_data.xml -
Copy:
fusion_plating/static/description/icon.png→fusion_plating_configurator/static/description/icon.png -
Step 1: Create top-level
__init__.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import models
- Step 2: Create
__manifest__.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
Fusion Plating — Configurator
==============================
Part of the Fusion Plating product family by Nexa Systems Inc.
Provides:
- Customer part catalog with geometry and material data
- Coating configuration templates (process, thickness, spec)
- Pre/post treatment library
- Formula-based pricing engine with complexity surcharges
- Configurator sessions that generate sale orders
- Custom sale order views with plating-specific fields
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
'sale_management',
],
'data': [
'security/fp_configurator_security.xml',
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'views/fp_treatment_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_coating_config_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/fp_configurator_menu.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}
- Step 3: Create
models/__init__.py(empty initially, populated as models are added)
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
- Step 4: Create security XML with role-based groups
File: security/fp_configurator_security.xml
<?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.
Role-based groups that control menu visibility. These work ALONGSIDE
the existing 4-level privilege hierarchy (Operator → Supervisor →
Manager → Administrator) defined in fusion_plating/security/fp_security.xml.
Privilege levels control CRUD permissions; role groups control what
menus and views a user sees.
-->
<odoo>
<!-- ================================================================== -->
<!-- ESTIMATOR ROLE — can see Sales, Configurator, Customers, Catalog -->
<!-- Requires at least Supervisor privilege level for write access. -->
<!-- ================================================================== -->
<record id="group_fp_estimator" model="res.groups">
<field name="name">Estimator</field>
<field name="category_id" ref="fusion_plating.module_category_fusion_plating"/>
<field name="implied_ids" eval="[(4, ref('fusion_plating.group_fusion_plating_supervisor'))]"/>
</record>
<!-- ================================================================== -->
<!-- SHOP MANAGER ROLE — sees everything. Implies all other roles. -->
<!-- ================================================================== -->
<record id="group_fp_shop_manager" model="res.groups">
<field name="name">Shop Manager</field>
<field name="category_id" ref="fusion_plating.module_category_fusion_plating"/>
<field name="implied_ids" eval="[
(4, ref('fusion_plating.group_fusion_plating_manager')),
(4, ref('group_fp_estimator')),
]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>
- Step 5: Create empty
ir.model.access.csv(header only, rows added per model)
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
- Step 6: Create sequence data file
File: data/fp_configurator_sequence_data.xml
<?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">
<record id="seq_fp_quote_configurator" model="ir.sequence">
<field name="name">Fusion Plating: Configurator</field>
<field name="code">fp.quote.configurator</field>
<field name="prefix">CFG-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>
- Step 7: Copy icon file
cp fusion_plating/static/description/icon.png fusion_plating_configurator/static/description/icon.png
- Step 8: Create placeholder view/menu XML files (empty
<odoo/>root so manifest doesn't error on install)
Create each of: fp_treatment_views.xml, fp_part_catalog_views.xml, fp_coating_config_views.xml, fp_pricing_rule_views.xml, fp_quote_configurator_views.xml, sale_order_views.xml, fp_configurator_menu.xml with:
<?xml version="1.0" encoding="utf-8"?>
<odoo></odoo>
- Step 9: Install the empty module to verify scaffold
docker exec odoo-dev-app odoo -d fusion-dev -i fusion_plating_configurator --stop-after-init
Expected: installs with no errors. Module appears in Apps list.
- Step 10: Commit
git add fusion_plating_configurator/
git commit -m "feat(configurator): module scaffold with security groups and sequences"
Task 2: fp.treatment Model + Views
The simplest model — establishes the pattern for all others.
Files:
-
Create:
fusion_plating_configurator/models/fp_treatment.py -
Modify:
fusion_plating_configurator/models/__init__.py -
Modify:
fusion_plating_configurator/security/ir.model.access.csv -
Modify:
fusion_plating_configurator/views/fp_treatment_views.xml -
Step 1: Create
fp_treatment.py
# -*- 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',
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.'),
]
- Step 2: Update
models/__init__.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_treatment
- Step 3: Add ACL rows to
ir.model.access.csv
Append to the CSV:
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
- Step 4: Create
fp_treatment_views.xml
<?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"/>
<field name="default_cost"/>
<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>
<field name="default_duration_minutes"/>
<field name="currency_id" invisible="1"/>
<field name="default_cost"/>
</group>
</group>
<group>
<field name="description" placeholder="Description of this treatment step..."/>
</group>
<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>
- Step 5: Upgrade module and verify
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init
Expected: no errors. Treatment model accessible via Python shell.
- Step 6: Commit
git add fusion_plating_configurator/
git commit -m "feat(configurator): fp.treatment model with views and ACL"
Task 3: fp.part.catalog Model + Views
Files:
-
Create:
fusion_plating_configurator/models/fp_part_catalog.py -
Modify:
fusion_plating_configurator/models/__init__.py— addfrom . import fp_part_catalog -
Modify:
fusion_plating_configurator/security/ir.model.access.csv— add 3 rows -
Modify:
fusion_plating_configurator/views/fp_part_catalog_views.xml -
Step 1: Create
fp_part_catalog.py
# -*- 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 FpPartCatalog(models.Model):
"""Customer part library.
Stores geometry, material, and complexity data for parts that
customers send repeatedly. New orders reference existing catalog
entries for instant re-quoting; one-off parts create new entries.
"""
_name = 'fp.part.catalog'
_description = 'Fusion Plating — Part Catalog'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'partner_id, part_number, name'
name = fields.Char(
string='Part Name',
required=True,
tracking=True,
help='Descriptive name for this part.',
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
ondelete='cascade',
tracking=True,
domain="[('customer_rank', '>', 0)]",
)
part_number = fields.Char(
string='Part Number',
tracking=True,
help="Customer's part number (e.g. VS-R392007E01).",
)
revision = fields.Char(
string='Revision',
help='Revision letter or number (e.g. Rev: 1B).',
)
substrate_material = fields.Selection(
[
('aluminium', 'Aluminium'),
('steel', 'Steel'),
('stainless', 'Stainless Steel'),
('copper', 'Copper'),
('titanium', 'Titanium'),
('other', 'Other'),
],
string='Substrate Material',
default='steel',
)
geometry_source = fields.Selection(
[
('3d_model', '3D Model'),
('manual', 'Manual Measurements'),
('pdf_drawing', 'PDF Drawing'),
],
string='Geometry Source',
default='manual',
)
# ----- File attachments -------------------------------------------------
model_attachment_id = fields.Many2one(
'ir.attachment',
string='3D Model File',
help='STEP, STL, or IGES file.',
)
drawing_attachment_ids = fields.Many2many(
'ir.attachment',
'fp_part_catalog_drawing_rel',
'part_catalog_id',
'attachment_id',
string='PDF Drawings',
)
# ----- Geometry measurements --------------------------------------------
surface_area = fields.Float(
string='Surface Area',
digits=(12, 4),
help='Total surface area to be plated.',
)
surface_area_uom = fields.Selection(
[
('sq_in', 'sq in'),
('sq_ft', 'sq ft'),
('sq_cm', 'sq cm'),
('sq_m', 'sq m'),
],
string='Surface Area UoM',
default='sq_in',
)
weight = fields.Float(
string='Weight (kg)',
digits=(12, 4),
help='Part weight for shipping cost calculation.',
)
dimensions_length = fields.Float(string='Length', digits=(12, 4))
dimensions_width = fields.Float(string='Width', digits=(12, 4))
dimensions_height = fields.Float(string='Height', digits=(12, 4))
# ----- Complexity -------------------------------------------------------
complexity = fields.Selection(
[
('simple', 'Simple'),
('moderate', 'Moderate'),
('complex', 'Complex'),
('very_complex', 'Very Complex'),
],
string='Complexity',
default='simple',
)
masking_zones = fields.Integer(
string='Masking Zones',
help='Number of areas requiring masking (not plated).',
)
masking_description = fields.Text(
string='Masking Description',
help='e.g. "Mask threaded holes, mask bore ID"',
)
has_blind_holes = fields.Boolean(string='Has Blind Holes')
has_recesses = fields.Boolean(string='Has Recesses')
has_threads = fields.Boolean(string='Has Threads')
notes = fields.Html(string='Notes')
active = fields.Boolean(string='Active', default=True)
_sql_constraints = [
('fp_part_catalog_partner_partnum_uniq',
'unique(partner_id, part_number)',
'Part number must be unique per customer.'),
]
-
Step 2: Update
models/__init__.py— addfrom . import fp_part_catalog -
Step 3: Add ACL rows — same pattern as treatment (operator=read, supervisor=read+write, manager=full)
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,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
- Step 4: Create
fp_part_catalog_views.xml— list (columns: Customer, Part#, Rev, Material, Surface Area, Complexity), form (title block, geometry tab, complexity tab, attachments tab, chatter), search (filter by customer, material, complexity, group by customer)
Follow the exact patterns from Task 2. Key additions:
-
List has
decoration-muted="not active" -
Form has
<div class="oe_chatter">at bottom -
Attachment fields use
widget="many2many_binary"for drawing_attachment_ids -
Step 5: Upgrade and verify
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init
- Step 6: Commit
git add fusion_plating_configurator/
git commit -m "feat(configurator): fp.part.catalog model — customer part library"
Task 4: fp.coating.config Model + Views
Files:
-
Create:
fusion_plating_configurator/models/fp_coating_config.py -
Modify:
models/__init__.py,ir.model.access.csv,fp_coating_config_views.xml -
Step 1: Create
fp_coating_config.py
# -*- 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),
help='Minimum coating thickness.',
)
thickness_max = fields.Float(
string='Max Thickness',
digits=(10, 4),
help='Maximum coating thickness.',
)
thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
string='Thickness UoM',
default='mils',
)
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')]",
)
sequence = fields.Integer(string='Sequence', default=10)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)
- Step 2: Update
__init__.py, add ACL rows, create views XML
Views: list (Name, Process, Phos Level, Thickness Range, Spec, Cert Level), form (title, two-column groups, notebook with treatments tab + description tab), search (filter by process type, cert level, phos level, group by process type).
-
Step 3: Upgrade and verify
-
Step 4: Commit
git commit -m "feat(configurator): fp.coating.config — coating configuration templates"
Task 5: fp.pricing.rule + fp.pricing.complexity.surcharge Models + Views
Files:
-
Create:
fusion_plating_configurator/models/fp_pricing_rule.py -
Create:
fusion_plating_configurator/models/fp_pricing_complexity_surcharge.py -
Modify:
models/__init__.py,ir.model.access.csv,fp_pricing_rule_views.xml -
Step 1: Create
fp_pricing_complexity_surcharge.py
# -*- 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 FpPricingComplexitySurcharge(models.Model):
"""Complexity-based surcharge line on a pricing rule."""
_name = 'fp.pricing.complexity.surcharge'
_description = 'Fusion Plating — Pricing Complexity Surcharge'
_order = 'complexity'
rule_id = fields.Many2one(
'fp.pricing.rule',
string='Pricing Rule',
required=True,
ondelete='cascade',
)
complexity = fields.Selection(
[
('simple', 'Simple'),
('moderate', 'Moderate'),
('complex', 'Complex'),
('very_complex', 'Very Complex'),
],
string='Complexity',
required=True,
)
surcharge_percent = fields.Float(
string='Surcharge %',
help='Additional percentage on top of base price for this complexity level.',
)
_sql_constraints = [
('fp_pricing_surcharge_rule_complexity_uniq',
'unique(rule_id, complexity)',
'Only one surcharge per complexity level per rule.'),
]
- Step 2: Create
fp_pricing_rule.py
# -*- 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 FpPricingRule(models.Model):
"""Formula-based pricing rule.
Rules are matched by coating config, substrate material, and
certification level. The first matching rule (by sequence) wins.
Global rules (no filters set) act as fallbacks.
"""
_name = 'fp.pricing.rule'
_description = 'Fusion Plating — Pricing Rule'
_order = 'sequence, id'
name = fields.Char(
string='Rule Name',
required=True,
help='Descriptive name for this pricing rule.',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Config',
help='Leave blank for a global rule that applies to all coatings.',
)
substrate_material = fields.Selection(
[
('aluminium', 'Aluminium'),
('steel', 'Steel'),
('stainless', 'Stainless Steel'),
('copper', 'Copper'),
('titanium', 'Titanium'),
('other', 'Other'),
],
string='Substrate Material',
help='Leave blank to match all materials.',
)
certification_level = fields.Selection(
[
('commercial', 'Commercial'),
('mil_spec', 'Mil-Spec'),
('nadcap', 'Nadcap'),
('nuclear', 'Nuclear (CSA N299)'),
],
string='Certification Level',
help='Leave blank to match all levels.',
)
pricing_method = fields.Selection(
[
('per_sqin', 'Per Square Inch'),
('per_sqft', 'Per Square Foot'),
('per_piece', 'Per Piece'),
('flat_rate', 'Flat Rate'),
],
string='Pricing Method',
required=True,
default='per_sqin',
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
base_rate = fields.Monetary(
string='Base Rate',
currency_field='currency_id',
help='Price per unit (sq in, sq ft, piece, or flat).',
)
thickness_factor = fields.Float(
string='Thickness Factor',
default=1.0,
help='Multiplier per mil of coating thickness. 1.0 = no adjustment.',
)
complexity_surcharge_ids = fields.One2many(
'fp.pricing.complexity.surcharge',
'rule_id',
string='Complexity Surcharges',
)
masking_rate_per_zone = fields.Monetary(
string='Masking Rate / Zone',
currency_field='currency_id',
help='Additional charge per masking zone.',
)
setup_fee = fields.Monetary(
string='Setup Fee',
currency_field='currency_id',
help='One-time setup fee per batch.',
)
minimum_charge = fields.Monetary(
string='Minimum Charge',
currency_field='currency_id',
help='Floor price — quote will not go below this.',
)
rush_surcharge_percent = fields.Float(
string='Rush Surcharge %',
help='Premium percentage for rush orders.',
)
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
notes = fields.Text(string='Notes')
-
Step 3: Update
__init__.py, add ACL rows for both models -
Step 4: Create
fp_pricing_rule_views.xml— list (Seq, Name, Coating, Substrate, Cert, Method, Base Rate, Min Charge), form (filters group, pricing group, surcharges inline list, notes), search (filter by method, coating, group by coating) -
Step 5: Upgrade and verify
-
Step 6: Commit
git commit -m "feat(configurator): fp.pricing.rule — formula-based pricing engine"
Task 6: sale.order Extensions
Files:
-
Create:
fusion_plating_configurator/models/sale_order.py -
Modify:
models/__init__.py -
Modify:
views/sale_order_views.xml -
Step 1: Create
sale_order.py— extends sale.order with x_fc_* fields
# -*- 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 SaleOrder(models.Model):
_inherit = 'sale.order'
# ----- Configurator link ------------------------------------------------
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',
)
# ----- PO tracking ------------------------------------------------------
x_fc_po_number = fields.Char(
string='Customer PO #',
tracking=True,
)
x_fc_po_attachment_id = fields.Many2one(
'ir.attachment',
string='PO Document',
)
x_fc_po_received = fields.Boolean(
string='PO Received',
tracking=True,
)
x_fc_po_override = fields.Boolean(
string='PO Override',
help='Manager override — proceed without formal PO (handshake deal).',
)
x_fc_po_override_reason = fields.Text(
string='Override Reason',
)
# ----- Invoice strategy -------------------------------------------------
x_fc_invoice_strategy = fields.Selection(
[
('deposit', 'Deposit'),
('progress', 'Progress Billing'),
('net_terms', 'Net Terms'),
('cod_prepay', 'COD / Prepay'),
],
string='Invoice Strategy',
tracking=True,
)
x_fc_deposit_percent = fields.Float(
string='Deposit %',
help='Deposit percentage if strategy is Deposit.',
)
# ----- Job details ------------------------------------------------------
x_fc_rush_order = fields.Boolean(
string='Rush Order',
tracking=True,
)
x_fc_delivery_method = fields.Selection(
[
('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup'),
],
string='Delivery Method',
tracking=True,
)
x_fc_receiving_status = fields.Selection(
[
('not_received', 'Not Received'),
('partial', 'Partial'),
('received', 'Received'),
('inspected', 'Inspected'),
],
string='Receiving Status',
default='not_received',
tracking=True,
)
- Step 2: Create
sale_order_views.xml— inherit sale.order form to add a "Plating" notebook tab with the x_fc_* fields, inherit list view to add key columns
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Inherit SO Form — add Plating tab ===== -->
<record id="view_sale_order_form_fp" model="ir.ui.view">
<field name="name">sale.order.form.fp.configurator</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<group>
<group string="Part & Coating">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
</group>
<group string="Customer PO">
<field name="x_fc_po_number"/>
<field name="x_fc_po_attachment_id"/>
<field name="x_fc_po_received"/>
<field name="x_fc_po_override"
groups="fusion_plating.group_fusion_plating_manager"/>
<field name="x_fc_po_override_reason"
invisible="not x_fc_po_override"/>
</group>
</group>
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status" readonly="1"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ===== Custom SO List View for Fusion Plating ===== -->
<record id="view_sale_order_list_fp" model="ir.ui.view">
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>
<field name="x_fc_part_catalog_id" optional="show"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="x_fc_receiving_status" widget="badge"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
<field name="x_fc_delivery_method" optional="show"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ===== Window Action — Quotations (for Fusion Plating menu) ===== -->
<record id="action_fp_quotations" model="ir.actions.act_window">
<field name="name">Quotations</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('draft', 'sent'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
<field name="context">{'default_x_fc_delivery_method': 'shipping_partner'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quotation
</p>
</field>
</record>
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
<record id="action_fp_sale_orders" model="ir.actions.act_window">
<field name="name">Sale Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('sale', 'done'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
</record>
</odoo>
-
Step 3: Upgrade and verify — open an SO, confirm Plating tab appears
-
Step 4: Commit
git commit -m "feat(configurator): sale.order plating extensions + custom list/form views"
Task 7: fp.quote.configurator Model + Price Calculation
Files:
-
Create:
fusion_plating_configurator/models/fp_quote_configurator.py -
Modify:
models/__init__.py,ir.model.access.csv,fp_quote_configurator_views.xml -
Step 1: Create
fp_quote_configurator.py— the core model with_compute_price()andaction_create_quotation()
# -*- 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, _
from odoo.exceptions import UserError
class FpQuoteConfigurator(models.Model):
"""Persistent configurator session.
Collects part geometry, coating config, and pricing inputs.
Calculates a price from matching pricing rules. The estimator
can override the calculated price. Creates a sale.order when confirmed.
"""
_name = 'fp.quote.configurator'
_description = 'Fusion Plating — Quote Configurator'
_inherit = ['mail.thread']
_order = 'create_date desc'
name = fields.Char(
string='Reference',
readonly=True,
copy=False,
default='New',
)
state = fields.Selection(
[('draft', 'Draft'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled')],
string='Status',
default='draft',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
domain="[('customer_rank', '>', 0)]",
)
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part (Catalog)',
domain="[('partner_id', '=', partner_id)]",
help='Select from this customer\'s part catalog, or leave blank for a one-off.',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
required=True,
)
quantity = fields.Integer(string='Quantity', default=1, required=True)
batch_size = fields.Integer(
string='Batch Size',
help='Parts per rack or barrel load.',
)
# ----- Geometry (auto-filled from catalog or entered manually) ----------
surface_area = fields.Float(string='Surface Area', digits=(12, 4))
surface_area_uom = fields.Selection(
[('sq_in', 'sq in'), ('sq_ft', 'sq ft'), ('sq_cm', 'sq cm'), ('sq_m', 'sq m')],
string='Area UoM',
default='sq_in',
)
thickness_requested = fields.Float(string='Requested Thickness', digits=(10, 4))
masking_zones = fields.Integer(string='Masking Zones')
complexity = fields.Selection(
[('simple', 'Simple'), ('moderate', 'Moderate'),
('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity',
default='simple',
)
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
string='Substrate',
default='steel',
)
# ----- Options ----------------------------------------------------------
rush_order = fields.Boolean(string='Rush Order')
turnaround_days = fields.Integer(string='Turnaround (days)')
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method',
default='shipping_partner',
)
# ----- Pricing ----------------------------------------------------------
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
shipping_fee = fields.Monetary(string='Shipping Fee', currency_field='currency_id')
delivery_fee = fields.Monetary(string='Delivery Fee', currency_field='currency_id')
calculated_price = fields.Monetary(
string='Calculated Price',
currency_field='currency_id',
compute='_compute_price',
store=True,
)
price_breakdown_html = fields.Html(
string='Price Breakdown',
compute='_compute_price',
store=True,
)
estimator_override_price = fields.Monetary(
string='Final Price',
currency_field='currency_id',
help='Estimator can override the calculated price.',
)
# ----- SO link ----------------------------------------------------------
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
readonly=True,
copy=False,
)
notes = fields.Text(string='Notes')
# -------------------------------------------------------------------------
# Auto-population from catalog
# -------------------------------------------------------------------------
@api.onchange('part_catalog_id')
def _onchange_part_catalog_id(self):
if self.part_catalog_id:
cat = self.part_catalog_id
self.surface_area = cat.surface_area
self.surface_area_uom = cat.surface_area_uom
self.complexity = cat.complexity
self.masking_zones = cat.masking_zones
self.substrate_material = cat.substrate_material
@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
# -------------------------------------------------------------------------
# Price calculation
# -------------------------------------------------------------------------
@api.depends(
'surface_area', 'surface_area_uom', 'thickness_requested',
'masking_zones', 'complexity', 'substrate_material',
'quantity', 'batch_size', 'rush_order',
'shipping_fee', 'delivery_fee',
'coating_config_id', 'coating_config_id.certification_level',
)
def _compute_price(self):
for rec in self:
if not rec.coating_config_id or not rec.surface_area:
rec.calculated_price = 0
rec.price_breakdown_html = ''
continue
rule = rec._find_matching_rule()
if not rule:
rec.calculated_price = 0
rec.price_breakdown_html = '<p class="text-muted">No matching pricing rule found.</p>'
continue
# --- Base calculation ---
area = rec._normalize_surface_area_to_sqin()
if rule.pricing_method == 'per_sqin':
unit_price = area * rule.base_rate
elif rule.pricing_method == 'per_sqft':
unit_price = (area / 144.0) * rule.base_rate
elif rule.pricing_method == 'per_piece':
unit_price = rule.base_rate
else: # flat_rate
unit_price = rule.base_rate
# --- Thickness factor ---
thickness = rec.thickness_requested or 1.0
unit_price *= (rule.thickness_factor * thickness) if rule.thickness_factor != 1.0 else 1.0
# --- Complexity surcharge ---
surcharge_pct = 0
for line in rule.complexity_surcharge_ids:
if line.complexity == rec.complexity:
surcharge_pct = line.surcharge_percent
break
unit_price *= (1 + surcharge_pct / 100.0)
# --- Masking ---
masking_cost = (rec.masking_zones or 0) * rule.masking_rate_per_zone
# --- Quantity ---
subtotal = (unit_price * rec.quantity) + masking_cost + rule.setup_fee
# --- Rush surcharge ---
rush_amount = 0
if rec.rush_order and rule.rush_surcharge_percent:
rush_amount = subtotal * (rule.rush_surcharge_percent / 100.0)
subtotal += rush_amount
# --- Minimum charge ---
if subtotal < rule.minimum_charge:
subtotal = rule.minimum_charge
# --- Delivery/shipping fees ---
total = subtotal + (rec.shipping_fee or 0) + (rec.delivery_fee or 0)
rec.calculated_price = total
# --- Build breakdown HTML ---
lines = [
f'<tr><td>Base ({rule.get_formfield_string("pricing_method")})</td>'
f'<td class="text-end">${unit_price:,.2f} x {rec.quantity}</td></tr>',
]
if masking_cost:
lines.append(f'<tr><td>Masking ({rec.masking_zones} zones)</td>'
f'<td class="text-end">${masking_cost:,.2f}</td></tr>')
if rule.setup_fee:
lines.append(f'<tr><td>Setup Fee</td>'
f'<td class="text-end">${rule.setup_fee:,.2f}</td></tr>')
if rush_amount:
lines.append(f'<tr><td>Rush Surcharge ({rule.rush_surcharge_percent}%)</td>'
f'<td class="text-end">${rush_amount:,.2f}</td></tr>')
if rec.shipping_fee:
lines.append(f'<tr><td>Shipping</td>'
f'<td class="text-end">${rec.shipping_fee:,.2f}</td></tr>')
if rec.delivery_fee:
lines.append(f'<tr><td>Delivery</td>'
f'<td class="text-end">${rec.delivery_fee:,.2f}</td></tr>')
lines.append(f'<tr class="fw-bold"><td>Total</td>'
f'<td class="text-end">${total:,.2f}</td></tr>')
rec.price_breakdown_html = (
f'<table class="table table-sm"><thead><tr>'
f'<th>Item</th><th class="text-end">Amount</th></tr></thead>'
f'<tbody>{"".join(lines)}</tbody></table>'
f'<p class="text-muted small">Rule: {rule.name} (seq {rule.sequence})</p>'
)
def _find_matching_rule(self):
"""Find the first pricing rule matching this configurator's filters."""
domain = [('active', '=', True)]
# Build progressive filter — most specific first
rules = self.env['fp.pricing.rule'].search(domain, 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
# Check coating config filter
if rule.coating_config_id:
if rule.coating_config_id != self.coating_config_id:
continue
score += 4
# Check substrate filter
if rule.substrate_material:
if rule.substrate_material != self.substrate_material:
continue
score += 2
# Check certification filter
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
# More specific rules preferred
if score > best_score:
best_score = score
best = rule
return best
def _normalize_surface_area_to_sqin(self):
"""Convert surface area to square inches for calculation."""
area = self.surface_area or 0
uom = self.surface_area_uom
if uom == 'sq_ft':
return area * 144.0
elif uom == 'sq_cm':
return area * 0.155
elif uom == 'sq_m':
return area * 1550.0
return area # sq_in
# -------------------------------------------------------------------------
# Actions
# -------------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'fp.quote.configurator') or 'New'
return super().create(vals_list)
def action_create_quotation(self):
"""Create a sale.order from this configurator session."""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('Only draft configurators can create quotations.'))
if self.sale_order_id:
raise UserError(_('A quotation has already been created for this configurator.'))
price = self.estimator_override_price or self.calculated_price
# Find or create a generic service product for plating
product = self.env.ref(
'fusion_plating_configurator.product_plating_service', raise_if_not_found=False
)
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1
)
if not product:
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
'type': 'service',
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
})
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,
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,
'name': (f'{self.coating_config_id.name} — '
f'{self.part_catalog_id.name or "Custom Part"} '
f'(x{self.quantity})'),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
})],
}
so = self.env['sale.order'].create(so_vals)
self.write({
'sale_order_id': so.id,
'state': 'confirmed',
})
self.message_post(
body=_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.') % (so.id, so.name),
)
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}
def action_cancel(self):
self.write({'state': 'cancelled'})
-
Step 2: Add ACL rows, update
__init__.py -
Step 3: Create
fp_quote_configurator_views.xml— form with header buttons (Create Quotation / Cancel), partner + catalog fields, coating config, geometry group, pricing group with breakdown, notes -
Step 4: Upgrade and test — create a pricing rule, then a configurator, verify price calculates
-
Step 5: Commit
git commit -m "feat(configurator): fp.quote.configurator — pricing engine + SO creation"
Task 8: Menu Restructure — Sales as Default Landing
Files:
-
Modify:
fusion_plating_configurator/views/fp_configurator_menu.xml -
Step 1: Create the full menu XML
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== SALES submenu under Fusion Plating root ===== -->
<menuitem id="menu_fp_sales"
name="Sales"
parent="fusion_plating.menu_fp_root"
sequence="1"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_quotations"
name="Quotations"
parent="menu_fp_sales"
action="action_fp_quotations"
sequence="10"/>
<menuitem id="menu_fp_sale_orders"
name="Sale Orders"
parent="menu_fp_sales"
action="action_fp_sale_orders"
sequence="20"/>
<menuitem id="menu_fp_customers"
name="Customers"
parent="menu_fp_sales"
action="action_fp_customers"
sequence="30"/>
<menuitem id="menu_fp_part_catalog"
name="Part Catalog"
parent="menu_fp_sales"
action="action_fp_part_catalog"
sequence="40"/>
<!-- ===== CONFIGURATOR submenu ===== -->
<menuitem id="menu_fp_configurator"
name="Configurator"
parent="fusion_plating.menu_fp_root"
sequence="2"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_new_quote"
name="New Quote"
parent="menu_fp_configurator"
action="action_fp_quote_configurator"
sequence="10"/>
<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_treatments"
name="Treatments"
parent="menu_fp_configurator"
action="action_fp_treatment"
sequence="40"/>
<!-- ===== Customers action (for menu) ===== -->
<record id="action_fp_customers" model="ir.actions.act_window">
<field name="name">Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('customer_rank', '>', 0)]</field>
<field name="context">{'default_customer_rank': 1}</field>
</record>
</odoo>
- Step 2: Update Fusion Plating root menu default action
Modify fusion_plating/views/fp_menu.xml to set the root menu's action to fusion_plating_configurator.action_fp_quotations so Quotations is the default landing when opening Fusion Plating app. This requires the configurator module to be installed — add a conditional or move the action reference.
Alternatively: set sequence="1" on the Sales submenu so it opens first when clicking "Plating" in the app drawer. Odoo opens the first visible menu item's action automatically.
-
Step 3: Upgrade and verify — click Plating app → should land on Quotations list
-
Step 4: Commit
git commit -m "feat(configurator): menu restructure — Sales as default landing in Fusion Plating"
Task 9: Seed Data — Default Treatments
Files:
-
Create:
fusion_plating_configurator/data/fp_treatment_data.xml -
Modify:
__manifest__.py— add todatalist -
Step 1: Create seed data for common treatments
<?xml version="1.0" encoding="utf-8"?>
<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>
-
Step 2: Upgrade and verify — treatments appear in the list
-
Step 3: Commit
git commit -m "feat(configurator): seed data — common pre/post treatments"
Task 10: Integration Test — Full Flow
- Step 1: Manual end-to-end test
- Open Plating app → should land on Quotations
- Navigate to Configurator → Treatments → verify seed data loaded
- Create a Coating Configuration (EN Mid-Phos AMS 2404, link to EN process type)
- Create a Pricing Rule (per_sqin, $0.05/sqin, setup fee $50, rush 25%)
- Navigate to Configurator → New Quote
- Select customer, select coating config, enter surface area = 100 sqin, qty = 50
- Verify calculated price = (100 * 0.05 * 50) + 50 = $300
- Click "Create Quotation" → verify SO created with Plating tab populated
- Open SO → confirm x_fc_* fields are populated
- Step 2: Verify permissions
- Log in as Operator user → should NOT see Sales or Configurator menus
- Log in as user with Estimator role → should see Sales and Configurator
- Log in as Shop Manager → should see everything
- Step 3: Final commit
git add -A
git commit -m "feat(configurator): Phase 1 complete — configurator + sales integration"
Summary
| Task | What | Files | Estimated |
|---|---|---|---|
| 1 | Module scaffold | 8 new files | Quick |
| 2 | fp.treatment model + views | 4 files | Quick |
| 3 | fp.part.catalog model + views | 4 files | Medium |
| 4 | fp.coating.config model + views | 4 files | Medium |
| 5 | fp.pricing.rule + surcharge | 5 files | Medium |
| 6 | sale.order extensions + views | 3 files | Medium |
| 7 | fp.quote.configurator + price calc | 4 files | Large |
| 8 | Menu restructure | 2 files | Quick |
| 9 | Seed data | 2 files | Quick |
| 10 | Integration test | 0 files | Manual |