diff --git a/fusion-plating/docs/superpowers/plans/2026-04-12-phase1-configurator-sales.md b/fusion-plating/docs/superpowers/plans/2026-04-12-phase1-configurator-sales.md new file mode 100644 index 00000000..d6243f36 --- /dev/null +++ b/fusion-plating/docs/superpowers/plans/2026-04-12-phase1-configurator-sales.md @@ -0,0 +1,1738 @@ +# 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`** + +```python +# -*- 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`** + +```python +# -*- 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) + +```python +# -*- 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 + + + + + + + + + + Estimator + + + + + + + + + Shop Manager + + + + + + +``` + +- [ ] **Step 5: Create empty `ir.model.access.csv`** (header only, rows added per model) + +```csv +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 + + + + + + Fusion Plating: Configurator + fp.quote.configurator + CFG- + 5 + + + + +``` + +- [ ] **Step 7: Copy icon file** + +```bash +cp fusion_plating/static/description/icon.png fusion_plating_configurator/static/description/icon.png +``` + +- [ ] **Step 8: Create placeholder view/menu XML files** (empty `` 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 + + +``` + +- [ ] **Step 9: Install the empty module to verify scaffold** + +```bash +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** + +```bash +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`** + +```python +# -*- 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`** + +```python +# -*- 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: + +```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 + + + + + + + fp.treatment.list + fp.treatment + + + + + + + + + + + + + + + fp.treatment.form + fp.treatment + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + + fp.treatment.search + fp.treatment + + + + + + + + + + + + + + + + + + Treatments + fp.treatment + list,form + + +

+ No treatments defined yet +

+

+ Add pre-treatment steps (bead blast, zincate, acid etch) and + post-treatment steps (bake, passivate, chromate seal). +

+
+
+ +
+``` + +- [ ] **Step 5: Upgrade module and verify** + +```bash +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** + +```bash +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`** + +```python +# -*- 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) + +```csv +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 `
` at bottom +- Attachment fields use `widget="many2many_binary"` for drawing_attachment_ids + +- [ ] **Step 5: Upgrade and verify** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_configurator --stop-after-init +``` + +- [ ] **Step 6: Commit** + +```bash +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`** + +```python +# -*- 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** + +```bash +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`** + +```python +# -*- 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`** + +```python +# -*- 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** + +```bash +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 + +```python +# -*- 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 + + + + + + sale.order.form.fp.configurator + sale.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sale.order.list.fp + sale.order + + + + + + + + + + + + + + + + + + Quotations + sale.order + list,form,kanban + [('state', 'in', ('draft', 'sent'))] + + {'default_x_fc_delivery_method': 'shipping_partner'} + +

+ Create a new quotation +

+
+
+ + + + Sale Orders + sale.order + list,form,kanban + [('state', 'in', ('sale', 'done'))] + + + +
+``` + +- [ ] **Step 3: Upgrade and verify** — open an SO, confirm Plating tab appears + +- [ ] **Step 4: Commit** + +```bash +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()` + +```python +# -*- 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 = '

No matching pricing rule found.

' + 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'Base ({rule.get_formfield_string("pricing_method")})' + f'${unit_price:,.2f} x {rec.quantity}', + ] + if masking_cost: + lines.append(f'Masking ({rec.masking_zones} zones)' + f'${masking_cost:,.2f}') + if rule.setup_fee: + lines.append(f'Setup Fee' + f'${rule.setup_fee:,.2f}') + if rush_amount: + lines.append(f'Rush Surcharge ({rule.rush_surcharge_percent}%)' + f'${rush_amount:,.2f}') + if rec.shipping_fee: + lines.append(f'Shipping' + f'${rec.shipping_fee:,.2f}') + if rec.delivery_fee: + lines.append(f'Delivery' + f'${rec.delivery_fee:,.2f}') + lines.append(f'Total' + f'${total:,.2f}') + + rec.price_breakdown_html = ( + f'' + f'' + f'{"".join(lines)}
ItemAmount
' + f'

Rule: {rule.name} (seq {rule.sequence})

' + ) + + 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 %s 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** + +```bash +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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + Customers + res.partner + list,form,kanban + [('customer_rank', '>', 0)] + {'default_customer_rank': 1} + + + +``` + +- [ ] **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** + +```bash +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 + + + + + + Alkaline Clean + pre + 10 + 15 + + + Acid Etch + pre + 20 + 10 + + + Zincate (Aluminium) + pre + 30 + 5 + + + Bead Blast + pre + 40 + 20 + + + Solvent Degrease + pre + 50 + 10 + + + + + Hydrogen Embrittlement Bake + post + 10 + 240 + + + Passivate + post + 20 + 30 + + + Chromate Seal + post + 30 + 15 + + + +``` + +- [ ] **Step 2: Upgrade and verify** — treatments appear in the list + +- [ ] **Step 3: Commit** + +```bash +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** + +```bash +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 |