docs(plating): spec — per-part description history (auto-version on order entry)

Dedicated fp.part.description.version model: latest auto-loads both internal +
customer-facing into a new order line; on SO confirm, a changed description
saves a new version titled "S#### · date". Browsable per-part history;
default_specification_text kept synced. SO surfaces only (not quotes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-29 19:38:20 -04:00
parent 1b0657bd76
commit 1ae83e187e

View File

@@ -0,0 +1,176 @@
# Per-Part Description History (auto-version on order entry)
**Date:** 2026-05-29
**Status:** Approved design — pending spec review → implementation plan
**Module:** `fusion_plating_configurator` (description fields, part catalog, SO + wizard live here)
## Goal
Give every part a **versioned description history**. On order entry the part's most-recent
internal + customer-facing descriptions auto-load into the line; if the operator changes
anything, confirming the order saves a **new version** titled by Sales Order # + date. The
history accumulates per part and is browsable on the part form, so operators always start
from "what we did last time" and can see how a part's description evolved.
## Background — current state (verified 2026-05-29)
The description system today has **two independent mechanisms and no versioning**:
1. **Per-part default**`fp.part.catalog.default_specification_text` (Text, customer-facing
**only**). Saved back to the part **once** (first order only — the save-back at
`fusion_plating_configurator/wizard/fp_direct_order_wizard.py:982` guards on
`not part.default_specification_text`), and auto-fills the line next time via the
`_DEFAULTS_BY_FIELD` map (`'line_description' → 'default_specification_text'`,
`fp_direct_order_line.py:657`). The **internal** description is never persisted.
2. **Curated template library**`fp.sale.description.template`
(`fusion_plating_configurator/models/fp_sale_description_template.py`): each row has
`internal_description` + `customer_facing_description` + `name` + `tag`, scoped by
`part_catalog_id` (→ customer → global fallback). The operator picks one from a dropdown
(`x_fc_description_template_id` + `_register_usage`). This is a hand-maintained pick-list,
**not** a history.
**Description field names differ by surface** (important for the design):
| Surface | Customer-facing field | Internal field |
|---|---|---|
| Direct-order / express wizard line (`fp.direct.order.line`) | `line_description` (`:329`) | `internal_description` (`:335`) |
| Real sale order line (`sale.order.line`) | `name` (standard Odoo) | `x_fc_internal_description` (`sale_order_line.py:52`) |
There is **no** per-order snapshot, no auto-titling, no `is_latest`, and no internal-description
persistence today.
> **Note on prior errant code:** a misfired exploration agent auto-generated an
> `fp_sale_description_version.py` (scoped to the SO line, versioned on template-apply) and
> edited `sale_order_line.py`/`__init__.py`. That work does **not** match this design (wrong
> scope + trigger) and was stashed (`git stash@{0}`), not committed. Implementation here is
> greenfield; the stash will be dropped.
## Locked decisions
| # | Decision |
|---|---|
| D1 | **Storage:** a dedicated, separate `fp.part.description.version` model — kept OUT of the curated `fp.sale.description.template` picker. |
| D2 | **Title:** `"<Sales Order #> · <date>"`, e.g. `"S00123 · 2026-05-29"`. |
| D3 | **Save trigger:** on **sale order confirm**, and **only if** the description differs from the part's latest version (dedup). Abandoned drafts never create versions. |
| D4 | **Auto-load:** the part's **latest** version auto-loads BOTH internal + customer-facing into a new order line (won't clobber text already typed). |
| D5 | **Legacy sync:** keep `part.default_specification_text` updated to the latest customer-facing text (no regressions for anything reading that field). Version history is the new source of truth; `default_specification_text` is a fallback when a part has no version yet. |
| D6 | **Scope:** all SO entry surfaces — direct-order wizard, express order entry, SO line form. **Quotes excluded** (no SO # to title with). |
## Design
### 1. Data model — `fp.part.description.version`
New model in `fusion_plating_configurator/models/fp_part_description_version.py`:
| Field | Type | Notes |
|---|---|---|
| `part_catalog_id` | M2O `fp.part.catalog` | required, `ondelete='cascade'`, indexed |
| `internal_description` | Text | snapshot of the line's internal description |
| `customer_facing_description` | Text | snapshot of the line's customer-facing description |
| `sale_order_id` | M2O `sale.order` | `ondelete='set null'` — the order that produced it |
| `sale_order_line_id` | M2O `sale.order.line` | `ondelete='set null'` — the exact line (optional) |
| `source_date` | Date | the order's date used in the title |
| `version_no` | Integer | per-part incrementing counter (1, 2, 3 …) |
| `name` | Char (stored compute) | `"<sale_order.name> · <source_date>"`; collision suffix `(2)` if a second version shares one order |
| `is_latest` | Boolean (stored compute) | True for the highest `version_no` per part — drives the cheap auto-load lookup |
| `partner_id` | M2O `res.partner` | related `part_catalog_id.partner_id`, stored (grouping) |
| `active` | Boolean | default True |
Snapshots are immutable (no edit UI). `version_no` is assigned at create as
`max(existing for part) + 1`. `is_latest` recomputes when a new version is added.
Add an inverse one2many on `fp.part.catalog`: `x_fc_description_version_ids`
(`part_catalog_id`), used by the history UI (§4).
### 2. Auto-load at order entry
When a part is set on a line and **both** description fields are empty, load the part's
latest version (`is_latest=True`): `customer_facing_description` → the customer-facing field,
`internal_description` → the internal field. Per surface:
- **Wizard line** (`fp.direct.order.line`, covers direct-order **and** express): on the
`part_catalog_id` change, write `line_description` + `internal_description`. This supersedes
the current customer-facing-only `default_specification_text` fill and now loads internal
too. **Fallback:** if the part has no version yet, fall back to `default_specification_text`
(customer-facing) as today, so legacy parts still pre-fill. Preserve the existing
first-time-part `push_to_defaults` behavior (untouched).
- **SO line** (`sale.order.line`): on the part being set (`x_fc_part_catalog_id`), same load
into `name` + `x_fc_internal_description` when empty.
"Empty" check prevents clobbering a deliberate edit or a template the operator just picked.
Coordinate with the existing `fusion_plating_quality` default-spec onchange inherit (it owns
`x_fc_default_customer_spec_id` fill) so the two don't fight — the version load runs first and
the spec-link fill is unaffected (different field).
### 3. Save on confirm (deduped)
Hook `sale.order.action_confirm` (in `fusion_plating_configurator/models/sale_order.py`
extend the existing override if present, else add one). Run it **after the SO name is
finalized** — this shop renames the SO to its parent number during `action_confirm`, so
reading `order.name` post-rename gives the title the real confirmed order number (exact
placement relative to the receiving/jobs confirm hooks is a planning detail). For each order
line carrying a part (`x_fc_part_catalog_id`):
1. Read the line's `name` (customer-facing) + `x_fc_internal_description` (internal),
stripped/normalized for comparison.
2. Look up the part's latest version. If **none**, or if either description **differs** from
the latest → create a new `fp.part.description.version`: part, both descriptions,
`sale_order_id`, `sale_order_line_id`, `source_date = order.date_order.date()`,
`version_no = (latest.version_no or 0) + 1`. The new row becomes `is_latest`.
3. Identical to latest → **no** new version (dedup).
4. **Sync legacy (D5):** set `part.default_specification_text = <latest customer-facing>`.
All writes that touch the part/version run with the right privileges (sudo where a low-priv
estimator can't write the part catalog — verify the estimator's ACL on `fp.part.catalog` and
the new model during planning; mirror the existing save-back's privilege handling).
### 4. History UI
On the part form's **Descriptions** tab (alongside the curated-template list), add a
read-only **"Description History"** sub-list bound to `x_fc_description_version_ids`, ordered
`version_no desc`. Columns: `name` (S#·date), a `customer_facing_description` preview,
`create_uid`, `create_date`, `sale_order_id`. No create/edit/delete from here (snapshots are
immutable; managers can archive via `active` if needed). Add an ACL row for the new model
(read for estimator+, create via the confirm hook which may sudo; write/unlink manager-only).
### 5. Edge cases
- **Same part on multiple lines of one order:** each *distinct* description creates its own
version (deduped against the running latest); identical lines collapse. Title collision →
`(2)` suffix.
- **Order cancelled after confirm:** the version stays (it reflected a real confirmed order);
no cleanup (YAGNI).
- **Editing a description on an already-confirmed order:** no new version unless re-confirmed
— accepted limitation for the MVP.
- **Legacy part (has `default_specification_text`, no version):** first order auto-loads from
the legacy field; confirming creates version #1 and starts the history.
### 6. Testing
- **Model:** `version_no` increments per part; `is_latest` flips to the newest; `name`
format = `"S#### · YYYY-MM-DD"`.
- **Save-on-confirm:** first confirm creates v1; re-order with identical description → no new
version; re-order with a changed description → v2; `default_specification_text` synced to
latest customer-facing each time.
- **Auto-load:** latest version fills both fields on an empty wizard line / SO line; does NOT
overwrite text already entered; legacy-fallback fills customer-facing when no version.
- **Multi-line same part:** dedup + title-collision suffix.
### 7. Out of scope / deferred
- Quotes / RFQ (`fp.quote.configurator`) versioning — no SO #; later.
- A "load a specific older version" picker on the line (auto-load-latest is the MVP; history
is browse-only for now).
- Backfilling history from existing historical SO lines.
### Files to touch
- **New:** `fusion_plating_configurator/models/fp_part_description_version.py`
- `fusion_plating_configurator/models/__init__.py` (import the new model)
- `fusion_plating_configurator/models/fp_part_catalog.py` (`x_fc_description_version_ids` o2m)
- `fusion_plating_configurator/models/sale_order.py` (confirm hook → create versions + sync)
- `fusion_plating_configurator/models/sale_order_line.py` (auto-load on SO line; read fields on confirm)
- `fusion_plating_configurator/wizard/fp_direct_order_line.py` (auto-load on part onchange, with legacy fallback)
- `fusion_plating_configurator/security/ir.model.access.csv` (new model ACL)
- `fusion_plating_configurator/views/fp_part_catalog_views.xml` (Description History sub-list) + a view for the new model
- `fusion_plating_configurator/__manifest__.py` (version bump; register new model views/security)