From 1ae83e187e3d97707385bd3f45980e9b78392766 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 29 May 2026 19:38:20 -0400 Subject: [PATCH] =?UTF-8?q?docs(plating):=20spec=20=E2=80=94=20per-part=20?= =?UTF-8?q?description=20history=20(auto-version=20on=20order=20entry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...6-05-29-part-description-history-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 fusion_plating/docs/superpowers/specs/2026-05-29-part-description-history-design.md diff --git a/fusion_plating/docs/superpowers/specs/2026-05-29-part-description-history-design.md b/fusion_plating/docs/superpowers/specs/2026-05-29-part-description-history-design.md new file mode 100644 index 00000000..60994358 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-29-part-description-history-design.md @@ -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:** `" · "`, 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)