feat(configurator): per-part description version model + part load/save helpers

fp.part.description.version: immutable per-part snapshots with version_no/
is_latest maintained in create(), titled "<SO#> · <date>". 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 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-29 19:55:14 -04:00
parent 9b18f77e06
commit 2ed3dcee58
7 changed files with 215 additions and 1 deletions

View File

@@ -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': """

View File

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

View File

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

View File

@@ -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 = '<SO name> · <date>', 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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_part_material_manager fp.part.material.manager model_fp_part_material fusion_plating.group_fp_manager 1 1 1 1
45 access_fp_so_job_sort_user fp.so.job.sort.user model_fp_so_job_sort base.group_user 1 1 1 0
46 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
47 access_fp_part_description_version_user fp.part.description.version.user model_fp_part_description_version base.group_user 1 0 0 0
48 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
49 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

View File

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

View File

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