From 2ed3dcee582378824ba6d6427dd70b34ec1a6236 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 29 May 2026 19:55:14 -0400 Subject: [PATCH] feat(configurator): per-part description version model + part load/save helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fp.part.description.version: immutable per-part snapshots with version_no/ is_latest maintained in create(), titled " · ". fp.part.catalog gains description_version_ids + _fp_resolve_line_descriptions (load latest, fallback to default_specification_text) and _fp_save_description_version (dedup + sync default). ACL mirrors fp.sale.description.template. Tests deferred to entech (local Docker unavailable this session). Co-Authored-By: Claude Opus 4.7 --- .../__manifest__.py | 2 +- .../models/__init__.py | 1 + .../models/fp_part_catalog.py | 55 +++++++++++++ .../models/fp_part_description_version.py | 80 +++++++++++++++++++ .../security/ir.model.access.csv | 3 + .../tests/__init__.py | 1 + .../tests/test_part_description_history.py | 74 +++++++++++++++++ 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating_configurator/models/fp_part_description_version.py create mode 100644 fusion_plating/fusion_plating_configurator/tests/test_part_description_history.py diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index ab9d22fe..d9265b35 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Configurator', - 'version': '19.0.22.7.1', + 'version': '19.0.22.8.0', 'category': 'Manufacturing/Plating', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'description': """ diff --git a/fusion_plating/fusion_plating_configurator/models/__init__.py b/fusion_plating/fusion_plating_configurator/models/__init__.py index 1d830f33..d0c473df 100644 --- a/fusion_plating/fusion_plating_configurator/models/__init__.py +++ b/fusion_plating/fusion_plating_configurator/models/__init__.py @@ -8,6 +8,7 @@ from . import fp_part_catalog from . import fp_pricing_complexity_surcharge from . import fp_pricing_rule from . import fp_sale_description_template +from . import fp_part_description_version from . import fp_so_job_sort from . import fp_quote_configurator from . import fp_serial 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 6ac7c051..89ba1257 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -402,6 +402,10 @@ class FpPartCatalog(models.Model): description_template_count = fields.Integer( compute='_compute_description_template_count', ) + description_version_ids = fields.One2many( + 'fp.part.description.version', 'part_catalog_id', + string='Description History', + ) _sql_constraints = [ ('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)', @@ -542,6 +546,57 @@ class FpPartCatalog(models.Model): for part in self: part.description_template_count = len(part.description_template_ids) + # ---- Description history (spec 2026-05-29) ------------------------ + def _fp_latest_description_version(self): + self.ensure_one() + return self.env['fp.part.description.version'].search([ + ('part_catalog_id', '=', self.id), ('is_latest', '=', True), + ], limit=1) + + def _fp_resolve_line_descriptions(self): + """{internal, customer_facing} to pre-fill a new order line. + Latest version wins; legacy default_specification_text is the + fallback (customer-facing only) for parts with no version yet.""" + self.ensure_one() + ver = self._fp_latest_description_version() + if ver: + return {'internal': ver.internal_description or '', + 'customer_facing': ver.customer_facing_description or ''} + return {'internal': '', + 'customer_facing': self.default_specification_text or ''} + + @staticmethod + def _fp_norm_desc(text): + return (text or '').strip() + + def _fp_save_description_version(self, internal_desc, customer_desc, + order=None, line=None): + """Create a new version iff the descriptions changed vs the latest + (normalized compare). Syncs default_specification_text (spec D5).""" + self.ensure_one() + latest = self._fp_latest_description_version() + norm = self._fp_norm_desc + if (latest + and norm(latest.internal_description) == norm(internal_desc) + and norm(latest.customer_facing_description) == norm(customer_desc)): + return latest # unchanged — no new version + vals = { + 'part_catalog_id': self.id, + 'internal_description': internal_desc or '', + 'customer_facing_description': customer_desc or '', + } + if order is not None: + vals['sale_order_id'] = order.id + vals['source_date'] = (order.date_order.date() + if order.date_order + else fields.Date.context_today(self)) + if line is not None: + vals['sale_order_line_id'] = line.id + ver = self.env['fp.part.description.version'].create(vals) + if customer_desc and customer_desc != self.default_specification_text: + self.default_specification_text = customer_desc + return ver + @api.depends('part_number', 'revision', 'name') @api.depends_context('fp_express_part_picker') def _compute_display_name(self): diff --git a/fusion_plating/fusion_plating_configurator/models/fp_part_description_version.py b/fusion_plating/fusion_plating_configurator/models/fp_part_description_version.py new file mode 100644 index 00000000..30d9f507 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/models/fp_part_description_version.py @@ -0,0 +1,80 @@ +# -*- 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, _ + + +class FpPartDescriptionVersion(models.Model): + """Immutable per-part snapshot of the internal + customer-facing + description entered on an order. A new version is written on sale + order confirm whenever the description changes + (fp.part.catalog._fp_save_description_version). The latest version + auto-loads into the next order line for the part. + + Spec: docs/superpowers/specs/2026-05-29-part-description-history-design.md + """ + _name = 'fp.part.description.version' + _description = 'Fusion Plating — Part Description Version' + _order = 'part_catalog_id, version_no desc, id desc' + + part_catalog_id = fields.Many2one( + 'fp.part.catalog', string='Part', required=True, + ondelete='cascade', index=True, + ) + internal_description = fields.Text(string='Internal Description') + customer_facing_description = fields.Text(string='Customer-Facing Description') + sale_order_id = fields.Many2one( + 'sale.order', string='Sale Order', ondelete='set null') + sale_order_line_id = fields.Many2one( + 'sale.order.line', string='Order Line', ondelete='set null') + source_date = fields.Date(string='Date') + version_no = fields.Integer(string='Version', readonly=True) + name = fields.Char(string='Reference', readonly=True) + is_latest = fields.Boolean(string='Latest', default=False, index=True) + partner_id = fields.Many2one( + 'res.partner', string='Customer', + related='part_catalog_id.partner_id', store=True, readonly=True, + ) + active = fields.Boolean(default=True) + + @api.model + def _fp_build_name(self, vals): + """Title = ' · ', with a '(n)' suffix if a version + with that title already exists for the part (e.g. two lines of the + same part on one order).""" + so = (self.env['sale.order'].browse(vals['sale_order_id']) + if vals.get('sale_order_id') else None) + order_ref = so.name if so and so.name else _('Manual') + d = vals.get('source_date') or fields.Date.context_today(self) + base = '%s · %s' % (order_ref, d) + part_id = vals.get('part_catalog_id') + if part_id: + dup = self.search_count([ + ('part_catalog_id', '=', part_id), + ('name', '=like', base + '%'), + ]) + if dup: + base = '%s (%d)' % (base, dup + 1) + return base + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + part_id = vals.get('part_catalog_id') + if part_id and not vals.get('version_no'): + prev = self.search( + [('part_catalog_id', '=', part_id)], + order='version_no desc', limit=1) + vals['version_no'] = (prev.version_no or 0) + 1 + if not vals.get('name'): + vals['name'] = self._fp_build_name(vals) + vals['is_latest'] = True + records = super().create(vals_list) + # Exactly one latest per part — flip prior latest rows off. + for rec in records: + rec.part_catalog_id.description_version_ids.filtered( + lambda v, r=rec: v.id != r.id and v.is_latest + ).write({'is_latest': False}) + return records diff --git a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv index 1cbecef7..3d4e0676 100644 --- a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv @@ -44,3 +44,6 @@ access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_mater access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fp_manager,1,1,1,1 access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0 access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fp_manager,1,1,1,1 +access_fp_part_description_version_user,fp.part.description.version.user,model_fp_part_description_version,base.group_user,1,0,0,0 +access_fp_part_description_version_estimator,fp.part.description.version.estimator,model_fp_part_description_version,fusion_plating.group_fp_sales_rep,1,1,1,0 +access_fp_part_description_version_manager,fp.part.description.version.manager,model_fp_part_description_version,fusion_plating.group_fp_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_configurator/tests/__init__.py b/fusion_plating/fusion_plating_configurator/tests/__init__.py index 937ad87d..4efa20ce 100644 --- a/fusion_plating/fusion_plating_configurator/tests/__init__.py +++ b/fusion_plating/fusion_plating_configurator/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_express_line_fields from . import test_express_so_line_fields from . import test_express_sale_order_fields from . import test_express_wizard_fields +from . import test_part_description_history diff --git a/fusion_plating/fusion_plating_configurator/tests/test_part_description_history.py b/fusion_plating/fusion_plating_configurator/tests/test_part_description_history.py new file mode 100644 index 00000000..949c45d3 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/tests/test_part_description_history.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Per-part description history (spec 2026-05-29).""" +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install', 'fp_desc_history') +class TestPartDescriptionHistory(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'DescCust'}) + cls.part = cls.env['fp.part.catalog'].create({ + 'partner_id': cls.partner.id, + 'part_number': 'DH-001', + 'revision': 'A', + 'name': 'Desc Part', + }) + + def _mk_version(self, internal, customer, **kw): + vals = { + 'part_catalog_id': self.part.id, + 'internal_description': internal, + 'customer_facing_description': customer, + } + vals.update(kw) + return self.env['fp.part.description.version'].create(vals) + + # ----- Task 1: model invariants ----- + def test_version_no_increments_and_is_latest_flips(self): + v1 = self._mk_version('int 1', 'cust 1') + v2 = self._mk_version('int 2', 'cust 2') + self.assertEqual(v1.version_no, 1) + self.assertEqual(v2.version_no, 2) + self.assertFalse(v1.is_latest) + self.assertTrue(v2.is_latest) + + def test_name_uses_order_and_date(self): + so = self.env['sale.order'].create({'partner_id': self.partner.id}) + v = self._mk_version('i', 'c', sale_order_id=so.id, + source_date='2026-05-29') + self.assertIn(so.name, v.name) + self.assertIn('2026-05-29', v.name) + + # ----- Task 2: part helpers ----- + def test_resolve_falls_back_to_default_spec(self): + self.part.default_specification_text = 'legacy cust' + descs = self.part._fp_resolve_line_descriptions() + self.assertEqual(descs['customer_facing'], 'legacy cust') + self.assertEqual(descs['internal'], '') + + def test_resolve_prefers_latest_version(self): + self.part.default_specification_text = 'legacy cust' + self._mk_version('hist int', 'hist cust') + descs = self.part._fp_resolve_line_descriptions() + self.assertEqual(descs['customer_facing'], 'hist cust') + self.assertEqual(descs['internal'], 'hist int') + + def test_save_dedups_when_unchanged(self): + self.part._fp_save_description_version('i', 'c') + self.part._fp_save_description_version('i', 'c') # identical + self.assertEqual( + self.env['fp.part.description.version'].search_count( + [('part_catalog_id', '=', self.part.id)]), 1) + + def test_save_creates_new_on_change_and_syncs_default(self): + self.part._fp_save_description_version('i1', 'c1') + self.part._fp_save_description_version('i1', 'c2') # changed + versions = self.env['fp.part.description.version'].search( + [('part_catalog_id', '=', self.part.id)]) + self.assertEqual(len(versions), 2) + self.assertEqual(self.part.default_specification_text, 'c2')