10 tasks covering: module scaffold, 7 models (treatment, part catalog, coating config, pricing rule, complexity surcharge, configurator, SO extensions), security groups, menu restructure, seed data, integration test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1739 lines
61 KiB
Markdown
1739 lines
61 KiB
Markdown
# 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
|
|
<?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)
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```bash
|
|
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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo></odoo>
|
|
```
|
|
|
|
- [ ] **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
|
|
<?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**
|
|
|
|
```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 `<div class="oe_chatter">` 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
|
|
<!-- ===== Inherit SO Form — add Plating tab ===== -->
|
|
<record id="view_sale_order_form_fp" model="ir.ui.view">
|
|
<field name="name">sale.order.form.fp.configurator</field>
|
|
<field name="model">sale.order</field>
|
|
<field name="inherit_id" ref="sale.view_order_form"/>
|
|
<field name="arch" type="xml">
|
|
<xpath expr="//notebook" position="inside">
|
|
<page string="Plating" name="plating_tab">
|
|
<group>
|
|
<group string="Part & Coating">
|
|
<field name="x_fc_configurator_id" readonly="1"/>
|
|
<field name="x_fc_part_catalog_id"/>
|
|
<field name="x_fc_coating_config_id"/>
|
|
</group>
|
|
<group string="Customer PO">
|
|
<field name="x_fc_po_number"/>
|
|
<field name="x_fc_po_attachment_id"/>
|
|
<field name="x_fc_po_received"/>
|
|
<field name="x_fc_po_override"
|
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
|
<field name="x_fc_po_override_reason"
|
|
invisible="not x_fc_po_override"/>
|
|
</group>
|
|
</group>
|
|
<group>
|
|
<group string="Invoicing">
|
|
<field name="x_fc_invoice_strategy"/>
|
|
<field name="x_fc_deposit_percent"
|
|
invisible="x_fc_invoice_strategy != 'deposit'"/>
|
|
</group>
|
|
<group string="Delivery">
|
|
<field name="x_fc_rush_order"/>
|
|
<field name="x_fc_delivery_method"/>
|
|
<field name="x_fc_receiving_status" readonly="1"/>
|
|
</group>
|
|
</group>
|
|
</page>
|
|
</xpath>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- ===== Custom SO List View for Fusion Plating ===== -->
|
|
<record id="view_sale_order_list_fp" model="ir.ui.view">
|
|
<field name="name">sale.order.list.fp</field>
|
|
<field name="model">sale.order</field>
|
|
<field name="arch" type="xml">
|
|
<list string="Sale Orders" decoration-info="state == 'draft'"
|
|
decoration-muted="state == 'cancel'">
|
|
<field name="name"/>
|
|
<field name="partner_id"/>
|
|
<field name="x_fc_po_number"/>
|
|
<field name="x_fc_part_catalog_id" optional="show"/>
|
|
<field name="x_fc_coating_config_id" optional="show"/>
|
|
<field name="amount_total" sum="Total"/>
|
|
<field name="x_fc_receiving_status" widget="badge"
|
|
decoration-warning="x_fc_receiving_status == 'not_received'"
|
|
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
|
|
<field name="x_fc_delivery_method" optional="show"/>
|
|
<field name="state" widget="badge"/>
|
|
</list>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- ===== Window Action — Quotations (for Fusion Plating menu) ===== -->
|
|
<record id="action_fp_quotations" model="ir.actions.act_window">
|
|
<field name="name">Quotations</field>
|
|
<field name="res_model">sale.order</field>
|
|
<field name="view_mode">list,form,kanban</field>
|
|
<field name="domain">[('state', 'in', ('draft', 'sent'))]</field>
|
|
<field name="view_ids" eval="[(5, 0, 0),
|
|
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
|
|
<field name="context">{'default_x_fc_delivery_method': 'shipping_partner'}</field>
|
|
<field name="help" type="html">
|
|
<p class="o_view_nocontent_smiling_face">
|
|
Create a new quotation
|
|
</p>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
|
|
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
|
<field name="name">Sale Orders</field>
|
|
<field name="res_model">sale.order</field>
|
|
<field name="view_mode">list,form,kanban</field>
|
|
<field name="domain">[('state', 'in', ('sale', 'done'))]</field>
|
|
<field name="view_ids" eval="[(5, 0, 0),
|
|
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
|
|
</record>
|
|
|
|
</odoo>
|
|
```
|
|
|
|
- [ ] **Step 3: Upgrade and verify** — open an SO, confirm Plating tab appears
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```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 = '<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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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 |
|