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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user