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:
@@ -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
|
||||
|
||||
@@ -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'})
|
||||
Reference in New Issue
Block a user