Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-04-12-phase1-configurator-sales.md
2026-04-16 20:53:53 -04:00

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.pngfusion_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 — add from . 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 — add from . 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 &amp; 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() and action_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 to data list

  • 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
  1. Open Plating app → should land on Quotations
  2. Navigate to Configurator → Treatments → verify seed data loaded
  3. Create a Coating Configuration (EN Mid-Phos AMS 2404, link to EN process type)
  4. Create a Pricing Rule (per_sqin, $0.05/sqin, setup fee $50, rush 25%)
  5. Navigate to Configurator → New Quote
  6. Select customer, select coating config, enter surface area = 100 sqin, qty = 50
  7. Verify calculated price = (100 * 0.05 * 50) + 50 = $300
  8. Click "Create Quotation" → verify SO created with Plating tab populated
  9. Open SO → confirm x_fc_* fields are populated
  • Step 2: Verify permissions
  1. Log in as Operator user → should NOT see Sales or Configurator menus
  2. Log in as user with Estimator role → should see Sales and Configurator
  3. 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