fix(configurator): part-level saved descriptions (not generic)
The earlier description templates were global — same 8 generic texts
applied to any part. That's useless when a customer has 3,500 parts
and each part has 3–5 canned variants (standard, masked threads,
masked bore, rework, rush packaging). That's ~17,500 rows total, and
the variants ONLY make sense in the context of a specific part number.
Restructured so descriptions live on each part:
Model changes:
fp.sale.description.template.part_catalog_id (new M2O, indexed,
ondelete cascade) — the primary scoping field
fp.sale.description.template.partner_id — now a related store=True
field pulled from the part, so customer-level search still works
fp.part.catalog.description_template_ids (new O2M inverse) — the
5–10 canned descriptions attached to this specific part
fp.part.catalog.description_template_count (computed)
UI changes:
Part Catalog form: new "Descriptions" notebook page with inline
editable list (sequence + name + tag + description + usage_count).
5 variants take 30 seconds to enter.
Part Catalog form: new smart button "Descriptions" showing the count,
jumps to the full list filtered by this part.
Template list view: part_catalog_id column added, list ordered by
part first. Search view adds Part filter + Part-Specific /
Generic (No Part) filters + Group By Part.
Wizard changes:
description_template_id domain now prioritises part-specific, falls
through to partner, coating, or generic on a single dynamic domain.
_onchange_suggest_template priority: part → customer → coating →
none. No longer auto-picks a random global template when a part
has its own.
Smoke-tested on VS-HSA201-B (Amphenol):
5 canned variants seeded on the part form
Wizard with this part auto-suggested the lowest-sequence one
The part's Descriptions smart button shows "5"
Bulk data entry path for the client's 3,500 parts: either use the
inline list on each part form, or import via CSV with the new
part_catalog_id column (external_id or DB id).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -190,6 +190,17 @@ class FpPartCatalog(models.Model):
|
||||
revision_count = fields.Integer(
|
||||
string='Revisions', compute='_compute_revision_count',
|
||||
)
|
||||
description_template_ids = fields.One2many(
|
||||
'fp.sale.description.template', 'part_catalog_id',
|
||||
string='Saved Descriptions',
|
||||
help='Canned descriptions for this specific part. When an order is '
|
||||
'created for this part, these show up first in the picker. '
|
||||
'Typically 3–5 variants per part covering different masking, '
|
||||
'packaging, or spec callouts.',
|
||||
)
|
||||
description_template_count = fields.Integer(
|
||||
compute='_compute_description_template_count',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
|
||||
@@ -301,6 +312,10 @@ class FpPartCatalog(models.Model):
|
||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
||||
])
|
||||
|
||||
def _compute_description_template_count(self):
|
||||
for part in self:
|
||||
part.description_template_count = len(part.description_template_ids)
|
||||
|
||||
def action_view_customer(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
|
||||
@@ -7,21 +7,28 @@ from odoo import fields, models
|
||||
|
||||
|
||||
class FpSaleDescriptionTemplate(models.Model):
|
||||
"""Reusable boilerplate descriptions for sale.order.line items.
|
||||
"""Saved description snippets — most often attached to a specific part.
|
||||
|
||||
Plating shops run the same customer part over and over with small
|
||||
variations (masking rules, special handling, packaging). Instead of
|
||||
retyping — or half-remembering — the description every time, the
|
||||
estimator picks a named template here, tweaks it, and the tweaked
|
||||
text lands on the SO line as its description.
|
||||
Real-world usage: a plating shop keeps 3–5 canned descriptions PER
|
||||
PART because the same customer part runs with different masking,
|
||||
packaging, or spec-callout variations. With 3,500 parts and 5
|
||||
variants each, that's ~17,500 rows — so descriptions are scoped
|
||||
primarily by part, with optional fallback to customer / coating /
|
||||
global.
|
||||
|
||||
When a user creates a new order:
|
||||
1. If a part is picked, show templates for that part first.
|
||||
2. Else show templates for the customer.
|
||||
3. Else show templates for the coating.
|
||||
4. Else show global (generic) templates.
|
||||
"""
|
||||
_name = 'fp.sale.description.template'
|
||||
_description = 'Fusion Plating — Sale Order Line Description Template'
|
||||
_order = 'sequence, name'
|
||||
_order = 'part_catalog_id, sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Template Name', required=True,
|
||||
help='Short name shown in the picker (e.g. "ENP — Standard Aluminium").',
|
||||
help='Short name shown in the picker (e.g. "Standard masking", "With threaded holes masked").',
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description', required=True,
|
||||
@@ -29,16 +36,25 @@ class FpSaleDescriptionTemplate(models.Model):
|
||||
'it lands on the order line.',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
ondelete='cascade', index=True,
|
||||
help='If set, this description belongs to one specific customer '
|
||||
'part — it only appears in the picker when this part is on '
|
||||
'the order. Leave blank for generic fallback templates.',
|
||||
)
|
||||
# Related fields — surface the part's partner/coating for search &
|
||||
# grouping without writing them twice.
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
related='part_catalog_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
# Keep the explicit coating slot for global templates that aren't
|
||||
# part-specific but are still coating-specific.
|
||||
coating_config_id = fields.Many2one(
|
||||
'fp.coating.config', string='Associated Coating',
|
||||
ondelete='set null',
|
||||
help='If set, this template is offered first when this coating is '
|
||||
'chosen on the order.',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer (optional)',
|
||||
ondelete='set null',
|
||||
help='If set, restrict this template to a specific customer.',
|
||||
help='For generic (no-part) templates, restrict to one coating.',
|
||||
)
|
||||
tag = fields.Selection(
|
||||
[('standard', 'Standard'),
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
invisible="revision_count < 2">
|
||||
<field name="revision_count" widget="statinfo" string="Revisions"/>
|
||||
</button>
|
||||
<button name="%(action_fp_sale_description_template)d"
|
||||
type="action"
|
||||
class="oe_stat_button"
|
||||
icon="fa-file-text-o"
|
||||
context="{'search_default_part_catalog_id': id, 'default_part_catalog_id': id}">
|
||||
<field name="description_template_count" widget="statinfo" string="Descriptions"/>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<widget name="web_ribbon" title="Superseded" bg_color="text-bg-warning" invisible="is_latest_revision"/>
|
||||
@@ -181,6 +188,28 @@
|
||||
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
|
||||
</div>
|
||||
</page>
|
||||
<page string="Descriptions" name="descriptions">
|
||||
<p class="text-muted">
|
||||
Canned descriptions for this part. Whichever one the
|
||||
estimator picks on the order wizard lands on the SO
|
||||
line — they can tweak the text before confirming.
|
||||
Typically 3–5 variants per part (e.g. <em>Standard</em>,
|
||||
<em>With threaded holes masked</em>, <em>Special
|
||||
packaging</em>).
|
||||
</p>
|
||||
<field name="description_template_ids"
|
||||
context="{'default_part_catalog_id': id, 'default_partner_id': partner_id}">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name" placeholder="e.g. Standard, or With masking, etc."/>
|
||||
<field name="tag"/>
|
||||
<field name="description"
|
||||
placeholder="Full description text that lands on the order line..."/>
|
||||
<field name="usage_count" readonly="1" optional="show"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Revision History" name="revisions"
|
||||
invisible="not parent_part_id and not revision_ids">
|
||||
<field name="revision_ids" mode="list">
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
<field name="arch" type="xml">
|
||||
<list multi_edit="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="part_catalog_id" optional="show"/>
|
||||
<field name="name"/>
|
||||
<field name="tag" widget="badge"
|
||||
decoration-info="tag == 'standard'"
|
||||
decoration-warning="tag == 'masking'"
|
||||
decoration-danger="tag == 'rework'"
|
||||
decoration-success="tag in ('aerospace','nuclear')"/>
|
||||
<field name="coating_config_id" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="coating_config_id" optional="hide"/>
|
||||
<field name="usage_count" string="Used"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
@@ -40,11 +41,14 @@
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="partner_id" readonly="part_catalog_id"/>
|
||||
<field name="tag"/>
|
||||
<field name="coating_config_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="coating_config_id"
|
||||
help="Only used for generic (no-part) templates."
|
||||
invisible="part_catalog_id"/>
|
||||
<field name="sequence"/>
|
||||
<field name="usage_count" readonly="1"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
@@ -66,15 +70,22 @@
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="description"/>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="coating_config_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="tag"/>
|
||||
<filter name="active" string="Active" domain="[('active','=',True)]"/>
|
||||
<filter name="with_part" string="Part-Specific"
|
||||
domain="[('part_catalog_id','!=',False)]"/>
|
||||
<filter name="no_part" string="Generic (No Part)"
|
||||
domain="[('part_catalog_id','=',False)]"/>
|
||||
<group>
|
||||
<filter name="group_part" string="Part"
|
||||
context="{'group_by': 'part_catalog_id'}"/>
|
||||
<filter name="group_customer" string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
<filter name="group_tag" string="Category"
|
||||
context="{'group_by': 'tag'}"/>
|
||||
<filter name="group_coating" string="Coating"
|
||||
context="{'group_by': 'coating_config_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
||||
@@ -94,12 +94,20 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
|
||||
notes = fields.Text(string='Internal Notes')
|
||||
|
||||
# Description template picker (searchable / ajax-like via Odoo Many2one)
|
||||
# Description template picker — the domain is dynamically narrowed to
|
||||
# this part's canned descriptions first. When no part is chosen it
|
||||
# falls through to generic templates.
|
||||
description_template_id = fields.Many2one(
|
||||
'fp.sale.description.template',
|
||||
string='Description Template',
|
||||
help='Pick a saved boilerplate description and tweak it below. '
|
||||
'The final text lands on the sale order line.',
|
||||
domain="[('active','=',True), "
|
||||
" '|', '|', '|', "
|
||||
" ('part_catalog_id','=',part_catalog_id), "
|
||||
" ('part_catalog_id','=',False), "
|
||||
" ('partner_id','=',partner_id), "
|
||||
" ('coating_config_id','=',coating_config_id)]",
|
||||
help='Pick a saved description and tweak it below. Part-specific '
|
||||
'descriptions appear first, then customer / coating / generic.',
|
||||
)
|
||||
line_description = fields.Text(
|
||||
string='Line Description',
|
||||
@@ -126,28 +134,55 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
if self.description_template_id:
|
||||
self.line_description = self.description_template_id.description
|
||||
|
||||
@api.onchange('coating_config_id', 'partner_id')
|
||||
@api.onchange('part_catalog_id', 'coating_config_id', 'partner_id')
|
||||
def _onchange_suggest_template(self):
|
||||
"""Offer a sensible default template based on coating + customer."""
|
||||
"""Offer a sensible default template — part-specific wins.
|
||||
|
||||
Priority (first non-empty result wins):
|
||||
1. This part's lowest-sequence active template
|
||||
2. This customer's templates (no part)
|
||||
3. This coating's templates (no part)
|
||||
4. Don't auto-pick — user has to choose
|
||||
"""
|
||||
if self.description_template_id or self.line_description:
|
||||
return # respect user's choice
|
||||
if not self.coating_config_id:
|
||||
return
|
||||
Template = self.env['fp.sale.description.template']
|
||||
# Prefer customer+coating match, then coating-only
|
||||
match = Template.search([
|
||||
('active', '=', True),
|
||||
('partner_id', '=', self.partner_id.id if self.partner_id else False),
|
||||
('coating_config_id', '=', self.coating_config_id.id),
|
||||
], limit=1)
|
||||
if not match:
|
||||
|
||||
# 1. Part-specific
|
||||
if self.part_catalog_id:
|
||||
match = Template.search([
|
||||
('active', '=', True),
|
||||
('part_catalog_id', '=', self.part_catalog_id.id),
|
||||
], order='sequence', limit=1)
|
||||
if match:
|
||||
self.description_template_id = match.id
|
||||
self.line_description = match.description
|
||||
return
|
||||
|
||||
# 2. Customer (no part)
|
||||
if self.partner_id:
|
||||
match = Template.search([
|
||||
('active', '=', True),
|
||||
('part_catalog_id', '=', False),
|
||||
('partner_id', '=', self.partner_id.id),
|
||||
], order='sequence', limit=1)
|
||||
if match:
|
||||
self.description_template_id = match.id
|
||||
self.line_description = match.description
|
||||
return
|
||||
|
||||
# 3. Coating (no part, no customer restriction)
|
||||
if self.coating_config_id:
|
||||
match = Template.search([
|
||||
('active', '=', True),
|
||||
('part_catalog_id', '=', False),
|
||||
('partner_id', '=', False),
|
||||
('coating_config_id', '=', self.coating_config_id.id),
|
||||
], limit=1)
|
||||
if match:
|
||||
self.description_template_id = match.id
|
||||
self.line_description = match.description
|
||||
], order='sequence', limit=1)
|
||||
if match:
|
||||
self.description_template_id = match.id
|
||||
self.line_description = match.description
|
||||
return
|
||||
|
||||
@api.onchange('coating_config_id', 'quantity', 'partner_id')
|
||||
def _onchange_lookup_price(self):
|
||||
|
||||
Reference in New Issue
Block a user