From a660f1f05d648492e255614e6601b177143dd7fc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 17:46:53 -0400 Subject: [PATCH] fix(configurator): part-level saved descriptions (not generic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../models/fp_part_catalog.py | 15 ++++ .../models/fp_sale_description_template.py | 46 ++++++++---- .../views/fp_part_catalog_views.xml | 29 ++++++++ .../fp_sale_description_template_views.xml | 21 ++++-- .../wizard/fp_direct_order_wizard.py | 71 ++++++++++++++----- 5 files changed, 144 insertions(+), 38 deletions(-) diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py index ed7ef1c7..1f4d4c14 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -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 { diff --git a/fusion_plating/fusion_plating_configurator/models/fp_sale_description_template.py b/fusion_plating/fusion_plating_configurator/models/fp_sale_description_template.py index 5fbbbe75..ffa11d37 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_sale_description_template.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_sale_description_template.py @@ -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'), diff --git a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml index 331c0159..488f960f 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_part_catalog_views.xml @@ -76,6 +76,13 @@ invisible="revision_count < 2"> + @@ -181,6 +188,28 @@ + +

+ 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. Standard, + With threaded holes masked, Special + packaging). +

+ + + + + + + + + + +
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml index d800fda2..59e6d305 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml @@ -14,14 +14,15 @@ + - + @@ -40,11 +41,14 @@ + + - - + @@ -66,15 +70,22 @@ + + + + + - diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py index 5f7d5f25..cec15193 100644 --- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py @@ -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):