Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-29-part-description-history-plan.md
gsinghpal 9b18f77e06 docs(plating): implementation plan for per-part description history
Bite-sized TDD plan: version model + part load/save helpers, save-on-confirm
hook, wizard + SO-line auto-load, and the part Descriptions-tab history list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:51:10 -04:00

24 KiB

Per-Part Description History — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Auto-save a versioned per-part description history on sale-order confirm, and auto-load the latest version (internal + customer-facing) into the next order line.

Architecture: A new fp.part.description.version model holds immutable per-part snapshots. All the logic lives in three reusable helpers on fp.part.catalog (_fp_latest_description_version, _fp_resolve_line_descriptions, _fp_save_description_version) so the order-entry surfaces and the confirm hook share one source of truth. The version model's create() maintains the version_no / is_latest invariant. Everything is in fusion_plating_configurator.

Tech Stack: Odoo 19 (Python ORM, @api.model_create_multi, @api.onchange), TransactionCase tests.

Spec: docs/superpowers/specs/2026-05-29-part-description-history-design.md

Conventions:

  • Local test runner (db modsdev): docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_plating_configurator -u fusion_plating_configurator --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
  • Local Docker is down this session — tests are written now and run on entech as part of the batched deploy (mirrors how the receiving/shipping feature is being verified). Static python -m py_compile <file> is the local sanity check.
  • Bump version in fusion_plating_configurator/__manifest__.py once (Task 1).
  • Field-name note: the part o2m is named description_version_ids (no x_fc_ prefix) to match the existing description_template_ids on this custom configurator model — the spec wrote x_fc_...; use description_version_ids.

Phase 1 — The version model

Task 1: fp.part.description.version model + ACL + registration

Files:

  • Create: fusion_plating_configurator/models/fp_part_description_version.py

  • Create: fusion_plating_configurator/tests/test_part_description_history.py

  • Modify: fusion_plating_configurator/models/__init__.py (after line 10)

  • Modify: fusion_plating_configurator/tests/__init__.py

  • Modify: fusion_plating_configurator/security/ir.model.access.csv (append 3 rows)

  • Modify: fusion_plating_configurator/__manifest__.py (version bump)

  • Step 1: Write the failing test

Create fusion_plating_configurator/tests/test_part_description_history.py:

# -*- 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)

Register it in fusion_plating_configurator/tests/__init__.py — append:

from . import test_part_description_history

(Check the file's existing imports first; add the line at the end.)

  • Step 2: Run the test, verify it FAILS
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
    --test-tags /fusion_plating_configurator -u fusion_plating_configurator \
    --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: FAIL — KeyError: 'fp.part.description.version' (model doesn't exist).

  • Step 3: Create the model

Create fusion_plating_configurator/models/fp_part_description_version.py:

# -*- 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

Add the import in fusion_plating_configurator/models/__init__.py right after the fp_sale_description_template line:

from . import fp_sale_description_template
from . import fp_part_description_version

Append 3 rows to fusion_plating_configurator/security/ir.model.access.csv:

access_fp_part_description_version_operator,fp.part.description.version.operator,model_fp_part_description_version,fusion_plating.group_fp_technician,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

Note: the description_version_ids o2m referenced in create() is added in Task 2. Implement Task 2's field in the same pass (or temporarily change the flip-loop to self.search([('part_catalog_id','=',rec.part_catalog_id.id),('is_latest','=',True),('id','!=',rec.id)]).write({'is_latest': False}) if running Task 1 standalone). Cleanest: do Task 1 + Task 2's field together before running tests.

Bump version in fusion_plating_configurator/__manifest__.py: 19.0.22.7.119.0.22.8.0.

  • Step 4: Run the test, verify it PASSES (after Task 2's field exists)

Same command as Step 2. Expected: PASS (2 tests).

  • Step 5: Commit (bundle with Task 2 — they share the model<->part relation)

Phase 2 — Part helpers (the shared brain)

Task 2: fp.part.catalog o2m + load/save helpers

Files:

  • Modify: fusion_plating_configurator/models/fp_part_catalog.py (add o2m near description_template_ids ~line 394; add 4 methods)

  • Modify: fusion_plating_configurator/tests/test_part_description_history.py (add tests)

  • Step 1: Write the failing tests — append to the test class:

    # ----- 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')
  • Step 2: Run, verify FAIL

Same runner command. Expected: FAIL — AttributeError: 'fp.part.catalog' object has no attribute '_fp_resolve_line_descriptions'.

  • Step 3: Add the o2m + helpers to fp_part_catalog.py

Right after the description_template_count field/compute (~line 403), add the o2m:

    description_version_ids = fields.One2many(
        'fp.part.description.version', 'part_catalog_id',
        string='Description History',
    )

Add these methods to the FpPartCatalog class (anywhere in the class body):

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

Confirm from odoo import api, fields, models (and _ if not present) are imported at the top of fp_part_catalog.pyfields/models are already used; add api if missing.

  • Step 4: Run, verify PASS — all Phase 1 + 2 tests green (6 tests).

  • Step 5: py_compile + commit

python -m py_compile fusion_plating_configurator/models/fp_part_description_version.py fusion_plating_configurator/models/fp_part_catalog.py
git add fusion_plating_configurator/models/fp_part_description_version.py \
        fusion_plating_configurator/models/fp_part_catalog.py \
        fusion_plating_configurator/models/__init__.py \
        fusion_plating_configurator/security/ir.model.access.csv \
        fusion_plating_configurator/tests/test_part_description_history.py \
        fusion_plating_configurator/tests/__init__.py \
        fusion_plating_configurator/__manifest__.py
git commit -m "feat(configurator): per-part description version model + load/save helpers

fp.part.description.version (immutable snapshots, version_no/is_latest) plus
_fp_resolve_line_descriptions (load latest, fallback to default_specification_text)
and _fp_save_description_version (dedup + sync) on fp.part.catalog."

Phase 3 — Save on confirm

Task 3: write a version per part-line on SO confirm

Files:

  • Modify: fusion_plating_configurator/models/sale_order.py (extend action_confirm, tail before return res, ~line 889)

  • Step 1: Add the hook

In action_confirm, replace the final return res (~line 889) with the version-save loop + return. The loop runs AFTER super() and the job-number loop, so so.name is the finalized (parent-number-renamed) order number:

        # Per-part description history (spec 2026-05-29). After super() +
        # the parent-number rename, so.name is the final order number.
        for so in self:
            for line in so.order_line:
                if line.display_type:
                    continue
                part = (line.x_fc_part_catalog_id
                        if 'x_fc_part_catalog_id' in line._fields else False)
                if not part:
                    continue
                part._fp_save_description_version(
                    internal_desc=line.x_fc_internal_description or '',
                    customer_desc=line.name or '',
                    order=so, line=line,
                )
        return res
  • Step 2: py_compile + manual smoke note
python -m py_compile fusion_plating_configurator/models/sale_order.py

Full confirm integration (the action_confirm chain pulls in jobs + receiving creation) is verified on entech in the deploy walkthrough — the dedup/save logic itself is already unit-covered by Task 2's helper tests.

  • Step 3: Commit
git add fusion_plating_configurator/models/sale_order.py
git commit -m "feat(configurator): save a part description version on SO confirm

Each part-bearing line writes a deduped version (latest order# + date) via
fp.part.catalog._fp_save_description_version after the parent-number rename."

Phase 4 — Auto-load at order entry

Task 4: wizard line auto-load (direct order + express)

Files:

  • Modify: fusion_plating_configurator/wizard/fp_direct_order_line.py (replace the block at lines 632-637)

  • Step 1: Replace the customer-facing-only seed with the version load

Replace exactly this block (currently ~632-637):

            # ---- Express line_description (Specification) ----
            # Only seed if currently empty (don't clobber operator-typed text).
            if not rec.line_description:
                spec_default = getattr(part, 'default_specification_text', None)
                if spec_default:
                    rec.line_description = spec_default

with:

            # ---- Description history auto-load (spec 2026-05-29) ----
            # Latest per-part version wins; _fp_resolve_line_descriptions
            # falls back to default_specification_text (customer-facing only)
            # for parts with no version yet. Loads BOTH fields; never
            # clobbers text the operator already typed.
            descs = part._fp_resolve_line_descriptions()
            if not rec.line_description and descs['customer_facing']:
                rec.line_description = descs['customer_facing']
            if not rec.internal_description and descs['internal']:
                rec.internal_description = descs['internal']
  • Step 2: py_compile + commit
python -m py_compile fusion_plating_configurator/wizard/fp_direct_order_line.py
git add fusion_plating_configurator/wizard/fp_direct_order_line.py
git commit -m "feat(configurator): wizard line auto-loads latest part description version

Loads internal + customer-facing from the part's latest version (fallback to
default_specification_text); never clobbers typed text. Express + direct order."

Task 5: SO line auto-load onchange

Files:

  • Modify: fusion_plating_configurator/models/sale_order_line.py (add an onchange)

  • Modify: fusion_plating_configurator/tests/test_part_description_history.py (add a test)

  • Step 1: Write the failing test — append:

    # ----- Task 5: SO line auto-load -----
    def test_so_line_onchange_loads_latest_version(self):
        self.part._fp_save_description_version('shop notes', 'cust spec')
        line = self.env['sale.order.line'].new({
            'x_fc_part_catalog_id': self.part.id,
        })
        line._fp_onchange_part_load_description()
        self.assertEqual(line.name, 'cust spec')
        self.assertEqual(line.x_fc_internal_description, 'shop notes')
  • Step 2: Run, verify FAILAttributeError: ... '_fp_onchange_part_load_description'.

  • Step 3: Add the onchange to sale_order_line.py:

    @api.onchange('x_fc_part_catalog_id')
    def _fp_onchange_part_load_description(self):
        """Pre-fill name (customer-facing) + x_fc_internal_description from
        the part's latest description version, when empty (spec 2026-05-29)."""
        for line in self:
            part = line.x_fc_part_catalog_id
            if not part:
                continue
            descs = part._fp_resolve_line_descriptions()
            if not line.name and descs['customer_facing']:
                line.name = descs['customer_facing']
            if not line.x_fc_internal_description and descs['internal']:
                line.x_fc_internal_description = descs['internal']

Confirm api is imported in sale_order_line.py (it uses fields; add api to the from odoo import ... line if missing).

  • Step 4: Run, verify PASS (7 tests total).

  • Step 5: Commit

git add fusion_plating_configurator/models/sale_order_line.py \
        fusion_plating_configurator/tests/test_part_description_history.py
git commit -m "feat(configurator): SO line auto-loads latest part description version"

Phase 5 — History UI

Task 6: Description History list on the part Descriptions tab

Files:

  • Modify: fusion_plating_configurator/views/fp_part_catalog_views.xml (after the description_template_ids field block, which starts at line 288 and closes with its </field>)

  • Step 1: Add the read-only history list

Find the closing </field> of the description_template_ids field (the editable list that starts at line 288). Immediately after it, insert:

                            <separator string="Description History (auto-saved per order)"/>
                            <field name="description_version_ids" readonly="1">
                                <list>
                                    <field name="version_no" string="#"/>
                                    <field name="name" string="Reference"/>
                                    <field name="customer_facing_description" string="Customer-Facing"/>
                                    <field name="sale_order_id" string="Order"/>
                                    <field name="create_uid" string="By"/>
                                    <field name="create_date" string="When"/>
                                </list>
                            </field>
  • Step 2: Validate XML well-formedness
python -c "import xml.dom.minidom; xml.dom.minidom.parse(r'fusion_plating_configurator/views/fp_part_catalog_views.xml'); print('XML_OK')"
  • Step 3: Commit
git add fusion_plating_configurator/views/fp_part_catalog_views.xml
git commit -m "feat(configurator): Description History list on the part Descriptions tab"

Phase 6 — Verify (batched with the entech deploy)

  • Step 1: Run the full configurator test suite (on entech during deploy, or locally once Docker is up)
docker exec odoo-modsdev-app odoo -d modsdev --test-enable \
    --test-tags /fusion_plating_configurator -u fusion_plating_configurator \
    --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: the 7 test_part_description_history tests pass + no regressions.

  • Step 2: Manual walkthrough on entech
  1. Create a direct order for a brand-new part, type an internal + customer-facing description, confirm. → On the part's Descriptions tab, a Description History row appears titled "S#### · <date>".
  2. Start another order for the same part. → Both descriptions auto-load from the latest version. Confirm without changes → no new version (deduped).
  3. Start a third order, change the description, confirm. → A second version appears; the latest now auto-loads on the next order.
  4. Confirm default_specification_text on the part equals the latest customer-facing text.

Notes / deferred

  • Quotes / RFQ versioning — out of scope (no SO #).
  • "Load a specific older version" picker on the line — deferred; auto-load-latest is the MVP, history is browse-only.
  • Backfill of history from existing SO lines — deferred.
  • Drop the parked git stash@{0} (errant agent's mismatched code) before/after implementation: git stash drop stash@{0}.