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:
gsinghpal
2026-04-18 17:46:53 -04:00
parent f340c87b6a
commit a660f1f05d
5 changed files with 144 additions and 38 deletions

View File

@@ -190,6 +190,17 @@ class FpPartCatalog(models.Model):
revision_count = fields.Integer( revision_count = fields.Integer(
string='Revisions', compute='_compute_revision_count', 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 35 variants per part covering different masking, '
'packaging, or spec callouts.',
)
description_template_count = fields.Integer(
compute='_compute_description_template_count',
)
_sql_constraints = [ _sql_constraints = [
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)', ('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), '|', ('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): def action_view_customer(self):
self.ensure_one() self.ensure_one()
return { return {

View File

@@ -7,21 +7,28 @@ from odoo import fields, models
class FpSaleDescriptionTemplate(models.Model): 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 Real-world usage: a plating shop keeps 35 canned descriptions PER
variations (masking rules, special handling, packaging). Instead of PART because the same customer part runs with different masking,
retyping or half-remembering — the description every time, the packaging, or spec-callout variations. With 3,500 parts and 5
estimator picks a named template here, tweaks it, and the tweaked variants each, that's ~17,500 rows — so descriptions are scoped
text lands on the SO line as its description. 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' _name = 'fp.sale.description.template'
_description = 'Fusion Plating — Sale Order Line Description Template' _description = 'Fusion Plating — Sale Order Line Description Template'
_order = 'sequence, name' _order = 'part_catalog_id, sequence, name'
name = fields.Char( name = fields.Char(
string='Template Name', required=True, 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( description = fields.Text(
string='Description', required=True, string='Description', required=True,
@@ -29,16 +36,25 @@ class FpSaleDescriptionTemplate(models.Model):
'it lands on the order line.', 'it lands on the order line.',
) )
sequence = fields.Integer(default=10) 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( coating_config_id = fields.Many2one(
'fp.coating.config', string='Associated Coating', 'fp.coating.config', string='Associated Coating',
ondelete='set null', ondelete='set null',
help='If set, this template is offered first when this coating is ' help='For generic (no-part) templates, restrict to one coating.',
'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.',
) )
tag = fields.Selection( tag = fields.Selection(
[('standard', 'Standard'), [('standard', 'Standard'),

View File

@@ -76,6 +76,13 @@
invisible="revision_count &lt; 2"> invisible="revision_count &lt; 2">
<field name="revision_count" widget="statinfo" string="Revisions"/> <field name="revision_count" widget="statinfo" string="Revisions"/>
</button> </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> </div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/> <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"/> <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"/> <field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
</div> </div>
</page> </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 35 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" <page string="Revision History" name="revisions"
invisible="not parent_part_id and not revision_ids"> invisible="not parent_part_id and not revision_ids">
<field name="revision_ids" mode="list"> <field name="revision_ids" mode="list">

View File

@@ -14,14 +14,15 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<list multi_edit="1"> <list multi_edit="1">
<field name="sequence" widget="handle"/> <field name="sequence" widget="handle"/>
<field name="part_catalog_id" optional="show"/>
<field name="name"/> <field name="name"/>
<field name="tag" widget="badge" <field name="tag" widget="badge"
decoration-info="tag == 'standard'" decoration-info="tag == 'standard'"
decoration-warning="tag == 'masking'" decoration-warning="tag == 'masking'"
decoration-danger="tag == 'rework'" decoration-danger="tag == 'rework'"
decoration-success="tag in ('aerospace','nuclear')"/> decoration-success="tag in ('aerospace','nuclear')"/>
<field name="coating_config_id" optional="show"/>
<field name="partner_id" optional="show"/> <field name="partner_id" optional="show"/>
<field name="coating_config_id" optional="hide"/>
<field name="usage_count" string="Used"/> <field name="usage_count" string="Used"/>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</list> </list>
@@ -40,11 +41,14 @@
</div> </div>
<group> <group>
<group> <group>
<field name="part_catalog_id"/>
<field name="partner_id" readonly="part_catalog_id"/>
<field name="tag"/> <field name="tag"/>
<field name="coating_config_id"/>
</group> </group>
<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="sequence"/>
<field name="usage_count" readonly="1"/> <field name="usage_count" readonly="1"/>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
@@ -66,15 +70,22 @@
<search> <search>
<field name="name"/> <field name="name"/>
<field name="description"/> <field name="description"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/> <field name="coating_config_id"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="tag"/> <field name="tag"/>
<filter name="active" string="Active" domain="[('active','=',True)]"/> <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> <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" <filter name="group_tag" string="Category"
context="{'group_by': 'tag'}"/> context="{'group_by': 'tag'}"/>
<filter name="group_coating" string="Coating"
context="{'group_by': 'coating_config_id'}"/>
</group> </group>
</search> </search>
</field> </field>

View File

@@ -94,12 +94,20 @@ class FpDirectOrderWizard(models.TransientModel):
notes = fields.Text(string='Internal Notes') 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( description_template_id = fields.Many2one(
'fp.sale.description.template', 'fp.sale.description.template',
string='Description Template', string='Description Template',
help='Pick a saved boilerplate description and tweak it below. ' domain="[('active','=',True), "
'The final text lands on the sale order line.', " '|', '|', '|', "
" ('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( line_description = fields.Text(
string='Line Description', string='Line Description',
@@ -126,28 +134,55 @@ class FpDirectOrderWizard(models.TransientModel):
if self.description_template_id: if self.description_template_id:
self.line_description = self.description_template_id.description 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): 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: if self.description_template_id or self.line_description:
return # respect user's choice return # respect user's choice
if not self.coating_config_id:
return
Template = self.env['fp.sale.description.template'] Template = self.env['fp.sale.description.template']
# Prefer customer+coating match, then coating-only
match = Template.search([ # 1. Part-specific
('active', '=', True), if self.part_catalog_id:
('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:
match = Template.search([ match = Template.search([
('active', '=', True), ('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), ('coating_config_id', '=', self.coating_config_id.id),
], limit=1) ], order='sequence', limit=1)
if match: if match:
self.description_template_id = match.id self.description_template_id = match.id
self.line_description = match.description self.line_description = match.description
return
@api.onchange('coating_config_id', 'quantity', 'partner_id') @api.onchange('coating_config_id', 'quantity', 'partner_id')
def _onchange_lookup_price(self): def _onchange_lookup_price(self):