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'})
|
||||
@@ -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,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 & Part">
|
||||
<field name="partner_id"/>
|
||||
<field name="part_catalog_id"/>
|
||||
</group>
|
||||
<group string="Coating & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user