feat(configurator): fp.quote.configurator — pricing engine + SO creation

Add the core configurator model that collects part geometry, coating
config, and pricing inputs, calculates a price from matching pricing
rules (scored by specificity), and creates sale orders on confirmation.

- fp.quote.configurator model with mail.thread, sequence numbering
- Stored computed price with full breakdown HTML table
- Estimator override price support
- Auto-population from part catalog and coating config onchanges
- Surface area normalization (sq in/ft/cm/m)
- Specificity-scored rule matching (coating > substrate > cert level)
- action_create_quotation creates SO with FP-SERVICE product
- Form/list/search views with statusbar and chatter
- ACL: operator (read), estimator (read/write/create), manager (full)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-12 18:35:08 -04:00
parent 5143245f57
commit d3e2614620
4 changed files with 516 additions and 1 deletions

View File

@@ -8,4 +8,5 @@ from . import fp_part_catalog
from . import fp_coating_config
from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_quote_configurator
from . import sale_order

View File

@@ -0,0 +1,342 @@
# -*- 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',
)
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
if rule.thickness_factor and rule.thickness_factor != 1.0:
unit_price *= rule.thickness_factor * thickness
# --- 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 = []
lines.append(
'<tr><td>Base (%s)</td><td class="text-end">$%.2f x %d</td></tr>'
% (dict(rule._fields['pricing_method'].selection).get(rule.pricing_method, ''),
unit_price, rec.quantity)
)
if masking_cost:
lines.append(
'<tr><td>Masking (%d zones)</td><td class="text-end">$%.2f</td></tr>'
% (rec.masking_zones, masking_cost)
)
if rule.setup_fee:
lines.append(
'<tr><td>Setup Fee</td><td class="text-end">$%.2f</td></tr>'
% rule.setup_fee
)
if rush_amount:
lines.append(
'<tr><td>Rush Surcharge (%.0f%%)</td><td class="text-end">$%.2f</td></tr>'
% (rule.rush_surcharge_percent, rush_amount)
)
if rec.shipping_fee:
lines.append(
'<tr><td>Shipping</td><td class="text-end">$%.2f</td></tr>'
% rec.shipping_fee
)
if rec.delivery_fee:
lines.append(
'<tr><td>Delivery</td><td class="text-end">$%.2f</td></tr>'
% rec.delivery_fee
)
lines.append(
'<tr class="fw-bold"><td>Total</td><td class="text-end">$%.2f</td></tr>'
% total
)
rec.price_breakdown_html = (
'<table class="table table-sm"><thead><tr>'
'<th>Item</th><th class="text-end">Amount</th></tr></thead>'
'<tbody>%s</tbody></table>'
'<p class="text-muted small">Rule: %s (seq %d)</p>'
% (''.join(lines), rule.name, rule.sequence)
)
def _find_matching_rule(self):
"""Find the best pricing rule matching this configurator's filters.
Scores rules by specificity -- most specific match wins.
If no rule matches filters, returns None.
"""
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None
best_score = -1
for rule in rules:
score = 0
if rule.coating_config_id:
if rule.coating_config_id != self.coating_config_id:
continue
score += 4
if rule.substrate_material:
if rule.substrate_material != self.substrate_material:
continue
score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score:
best_score = score
best = rule
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['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,
})
coating_name = self.coating_config_id.name if self.coating_config_id else ''
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_configurator_id': self.id,
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,
'name': '%s%s (x%d)' % (coating_name, part_name, 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'})

View File

@@ -14,3 +14,6 @@ access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fus
access_fp_pricing_surcharge_operator,fp.pricing.complexity.surcharge.operator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_surcharge_estimator,fp.pricing.complexity.surcharge.estimator,model_fp_pricing_complexity_surcharge,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_pricing_surcharge_operator fp.pricing.complexity.surcharge.operator model_fp_pricing_complexity_surcharge fusion_plating.group_fusion_plating_operator 1 0 0 0
15 access_fp_pricing_surcharge_estimator fp.pricing.complexity.surcharge.estimator model_fp_pricing_complexity_surcharge fusion_plating_configurator.group_fp_estimator 1 1 1 0
16 access_fp_pricing_surcharge_manager fp.pricing.complexity.surcharge.manager model_fp_pricing_complexity_surcharge fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_quote_configurator_operator fp.quote.configurator.operator model_fp_quote_configurator fusion_plating.group_fusion_plating_operator 1 0 0 0
18 access_fp_quote_configurator_estimator fp.quote.configurator.estimator model_fp_quote_configurator fusion_plating_configurator.group_fp_estimator 1 1 1 0
19 access_fp_quote_configurator_manager fp.quote.configurator.manager model_fp_quote_configurator fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -1,2 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo></odoo>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- ===== Configurator Form View ===== -->
<record id="view_fp_quote_configurator_form" model="ir.ui.view">
<field name="name">fp.quote.configurator.form</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<form string="Quote Configurator">
<header>
<button name="action_create_quotation"
string="Create Quotation"
type="object"
class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?"
invisible="state != 'draft'"/>
<button name="action_cancel"
string="Cancel"
type="object"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Customer + Part / Coating + Quantity -->
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
</group>
<group string="Coating &amp; Quantity">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="batch_size"/>
</group>
</group>
<!-- Geometry / Options -->
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
</group>
<group string="Options">
<field name="complexity"/>
<field name="masking_zones"/>
<field name="rush_order"/>
<field name="turnaround_days"/>
</group>
</group>
<!-- Delivery / Fees -->
<group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
<notebook>
<page string="Sale Order" name="sale_order">
<group>
<field name="sale_order_id" readonly="1"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this quote..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- ===== Configurator List View ===== -->
<record id="view_fp_quote_configurator_list" model="ir.ui.view">
<field name="name">fp.quote.configurator.list</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<list string="Quote Configurators"
decoration-info="state == 'draft'"
decoration-muted="state == 'cancelled'"
default_order="create_date desc">
<field name="create_date" string="Date"/>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="surface_area"/>
<field name="quantity"/>
<field name="currency_id" column_invisible="1"/>
<field name="calculated_price"/>
<field name="estimator_override_price" string="Final Price"/>
<field name="state" widget="badge"
decoration-success="state == 'confirmed'"
decoration-info="state == 'draft'"
decoration-danger="state == 'cancelled'"/>
</list>
</field>
</record>
<!-- ===== Configurator Search View ===== -->
<record id="view_fp_quote_configurator_search" model="ir.ui.view">
<field name="name">fp.quote.configurator.search</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<separator/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<group>
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
<filter string="Coating Config" name="group_coating" context="{'group_by': 'coating_config_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_quote_configurator" model="ir.actions.act_window">
<field name="name">Quote Configurator</field>
<field name="res_model">fp.quote.configurator</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
<field name="context">{'search_default_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quote configurator session
</p>
<p>
Select a customer and coating configuration, enter part geometry,
and the pricing engine will calculate a quote. The estimator can
override the calculated price before creating a sale order.
</p>
</field>
</record>
</odoo>