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:
@@ -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)
|
||||
Reference in New Issue
Block a user