diff --git a/fusion_plating/docs/superpowers/plans/2026-05-29-part-description-history-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-29-part-description-history-plan.md new file mode 100644 index 00000000..37b9f428 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-29-part-description-history-plan.md @@ -0,0 +1,565 @@ +# 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](../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 ` 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`: + +```python +# -*- 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: + +```python +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** + +```bash +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`: + +```python +# -*- 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 +``` + +Add the import in `fusion_plating_configurator/models/__init__.py` right after the `fp_sale_description_template` line: + +```python +from . import fp_sale_description_template +from . import fp_part_description_version +``` + +Append 3 rows to `fusion_plating_configurator/security/ir.model.access.csv`: + +```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.1` → `19.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: + +```python + # ----- 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: + +```python + 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): + +```python + # ---- 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.py` — `fields`/`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** + +```bash +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: + +```python + # 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** + +```bash +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** + +```bash +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): + +```python + # ---- 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: + +```python + # ---- 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** + +```bash +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: + +```python + # ----- 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 FAIL** — `AttributeError: ... '_fp_onchange_part_load_description'`. + +- [ ] **Step 3: Add the onchange** to `sale_order_line.py`: + +```python + @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** + +```bash +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 ``) + +- [ ] **Step 1: Add the read-only history list** + +Find the closing `` of the `description_template_ids` field (the editable list that starts at line 288). Immediately after it, insert: + +```xml + + + + + + + + + + + +``` + +- [ ] **Step 2: Validate XML well-formedness** + +```bash +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** + +```bash +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) + +```bash +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#### · "`. +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}`.