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>
This commit is contained in:
@@ -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 <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`:
|
||||||
|
|
||||||
|
```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 = '<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:
|
||||||
|
|
||||||
|
```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 `</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:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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#### · <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}`.
|
||||||
Reference in New Issue
Block a user