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>
11 KiB
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:
- 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 atfusion_plating_configurator/wizard/fp_direct_order_wizard.py:982guards onnot part.default_specification_text), and auto-fills the line next time via the_DEFAULTS_BY_FIELDmap ('line_description' → 'default_specification_text',fp_direct_order_line.py:657). The internal description is never persisted. - Curated template library —
fp.sale.description.template(fusion_plating_configurator/models/fp_sale_description_template.py): each row hasinternal_description+customer_facing_description+name+tag, scoped bypart_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 editedsale_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 thepart_catalog_idchange, writeline_description+internal_description. This supersedes the current customer-facing-onlydefault_specification_textfill and now loads internal too. Fallback: if the part has no version yet, fall back todefault_specification_text(customer-facing) as today, so legacy parts still pre-fill. Preserve the existing first-time-partpush_to_defaultsbehavior (untouched). - SO line (
sale.order.line): on the part being set (x_fc_part_catalog_id), same load intoname+x_fc_internal_descriptionwhen 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):
- Read the line's
name(customer-facing) +x_fc_internal_description(internal), stripped/normalized for comparison. - 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 becomesis_latest. - Identical to latest → no new version (dedup).
- 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_noincrements per part;is_latestflips to the newest;nameformat ="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_textsynced 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_idso2m)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 modelfusion_plating_configurator/__manifest__.py(version bump; register new model views/security)