From d3e26146200808642cba766e93fe352d6dd8425e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 12 Apr 2026 18:35:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(configurator):=20fp.quote.configurator=20?= =?UTF-8?q?=E2=80=94=20pricing=20engine=20+=20SO=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../models/__init__.py | 1 + .../models/fp_quote_configurator.py | 342 ++++++++++++++++++ .../security/ir.model.access.csv | 3 + .../views/fp_quote_configurator_views.xml | 171 ++++++++- 4 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py diff --git a/fusion-plating/fusion_plating_configurator/models/__init__.py b/fusion-plating/fusion_plating_configurator/models/__init__.py index 98bd4e24..4469994b 100644 --- a/fusion-plating/fusion_plating_configurator/models/__init__.py +++ b/fusion-plating/fusion_plating_configurator/models/__init__.py @@ -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 diff --git a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py new file mode 100644 index 00000000..fe6c60ab --- /dev/null +++ b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py @@ -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 = '

No matching pricing rule found.

' + continue + + # --- Base calculation --- + area = rec._normalize_surface_area_to_sqin() + if rule.pricing_method == 'per_sqin': + unit_price = area * rule.base_rate + elif rule.pricing_method == 'per_sqft': + unit_price = (area / 144.0) * rule.base_rate + elif rule.pricing_method == 'per_piece': + unit_price = rule.base_rate + else: # flat_rate + unit_price = rule.base_rate + + # --- Thickness factor --- + thickness = rec.thickness_requested or 1.0 + 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( + 'Base (%s)$%.2f x %d' + % (dict(rule._fields['pricing_method'].selection).get(rule.pricing_method, ''), + unit_price, rec.quantity) + ) + if masking_cost: + lines.append( + 'Masking (%d zones)$%.2f' + % (rec.masking_zones, masking_cost) + ) + if rule.setup_fee: + lines.append( + 'Setup Fee$%.2f' + % rule.setup_fee + ) + if rush_amount: + lines.append( + 'Rush Surcharge (%.0f%%)$%.2f' + % (rule.rush_surcharge_percent, rush_amount) + ) + if rec.shipping_fee: + lines.append( + 'Shipping$%.2f' + % rec.shipping_fee + ) + if rec.delivery_fee: + lines.append( + 'Delivery$%.2f' + % rec.delivery_fee + ) + lines.append( + 'Total$%.2f' + % total + ) + + rec.price_breakdown_html = ( + '' + '' + '%s
ItemAmount
' + '

Rule: %s (seq %d)

' + % (''.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 %s created.') % (so.id, so.name), + ) + return { + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'res_id': so.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_cancel(self): + self.write({'state': 'cancelled'}) diff --git a/fusion-plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion-plating/fusion_plating_configurator/security/ir.model.access.csv index 2c693652..aab9eee1 100644 --- a/fusion-plating/fusion_plating_configurator/security/ir.model.access.csv +++ b/fusion-plating/fusion_plating_configurator/security/ir.model.access.csv @@ -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 diff --git a/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml b/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml index 85d8c23e..71a99ce1 100644 --- a/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml +++ b/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml @@ -1,2 +1,171 @@ - + + + + + + fp.quote.configurator.form + fp.quote.configurator + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + + fp.quote.configurator.list + fp.quote.configurator + + + + + + + + + + + + + + + + + + + fp.quote.configurator.search + fp.quote.configurator + + + + + + + + + + + + + + + + + + + + + Quote Configurator + fp.quote.configurator + list,form + + {'search_default_draft': 1} + +

+ Create a new quote configurator session +

+

+ 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. +

+
+
+ +