# 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:** `" · "`, 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) | `" · "`; 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 = `. 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)