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(
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 = [
('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 {

View File

@@ -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 35 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'),

View File

@@ -76,6 +76,13 @@
invisible="revision_count &lt; 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 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"
invisible="not parent_part_id and not revision_ids">
<field name="revision_ids" mode="list">

View File

@@ -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>

View File

@@ -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):