feat(configurator): 19.0.22.1.0 — recipe-driven orders + auto-sync to part

Four customer-feedback fixes (G1-G4):

G1 — Part cell display redundancy. fp.part.catalog.display_name was
showing 'PART (Rev X) — Name' which duplicated with my Part cell widget's
separately-rendered revision + name rows. Added @api.depends_context
('fp_express_part_picker') to _compute_display_name: when the context
flag is True, display_name returns JUST the part_number. The Express
view passes the flag on the part_catalog_id field, so the picker shows
'9876699373' and the widget's row 2/3 show the rev + name.

G2 — Material/Process Tag is now the order's RECIPE, not a free-text
shop tag. Converted material_process from Char to Many2One(fusion.
plating.process.node) with domain [('node_type','=','recipe')] on both
fp.direct.order.wizard AND sale.order. Pre-migration (19.0.22.1.0/
pre-migrate.py) drops the old VARCHAR column so Odoo recreates as
INTEGER FK. Per dev-stage policy, old tag data is dropped.

G3 — Auto-apply order recipe to every line. New onchange
_onchange_material_process_apply_to_lines on the wizard: when the
header recipe is picked / changed, propagate to every line's
process_variant_id (unless the line has an explicit per-line override
that doesn't match the previous header value).

Plus an override on fp.direct.order.line.create that seeds new lines'
process_variant_id from wizard.material_process. So a newly-added
line auto-inherits the order's recipe.

G4 — Auto-sync line edits back to the part catalog. New
_fp_sync_to_part method called from create() + write() on
fp.direct.order.line. Tracked fields:
- line_description     → part.default_specification_text
- bake_instructions    → part.default_bake_instructions
- thickness_range      → part.x_fc_default_thickness_range
- masking_enabled      → part.default_masking_enabled
- process_variant_id   → part.default_process_variant_id

Future orders for the same part will auto-pull these updated defaults
via the existing _onchange_part_default_thickness chain. Last-write-
wins semantics across concurrent edits (acceptable per dev-stage).
This commit is contained in:
gsinghpal
2026-05-26 23:20:27 -04:00
parent 4c5ee6143c
commit 48c2a4bfe1
8 changed files with 190 additions and 10 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.22.0.0',
'version': '19.0.22.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""Pre-migration for 19.0.22.1.0 — material_process Char → Many2One.
The material_process field on fp.direct.order.wizard was originally a
free-text Char tag for shop-level metadata (e.g. "ENP-STEEL-HP-ADVANCED").
Per 2026-05-27 customer feedback, it should instead link directly to a
recipe (fusion.plating.process.node, node_type='recipe') so that picking
a tag auto-applies the recipe to every order line.
Drop the old VARCHAR column so Odoo can recreate it as an INTEGER FK
when the new field declaration loads. Per the Express Orders spec
section 12 (dev-stage, ignore past orders), losing the old Char values
is acceptable.
Idempotent — IF EXISTS guards mean a re-run is safe.
"""
def migrate(cr, version):
# Same model needs the same pre-migration on sale.order too
for table, column in (
('fp_direct_order_wizard', 'material_process'),
('sale_order', 'x_fc_material_process'),
):
cr.execute("""
SELECT data_type FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
""", (table, column))
row = cr.fetchone()
if not row:
continue
data_type = row[0]
# Only drop if the existing column is the old Char shape
if data_type in ('character varying', 'text'):
cr.execute(
f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column}"
)

View File

@@ -543,6 +543,7 @@ class FpPartCatalog(models.Model):
part.description_template_count = len(part.description_template_ids)
@api.depends('part_number', 'revision', 'name')
@api.depends_context('fp_express_part_picker')
def _compute_display_name(self):
"""Display = 'PART-NUMBER (Rev X) — Optional Name'.
@@ -552,8 +553,19 @@ class FpPartCatalog(models.Model):
Defensive: some legacy rows stored revision as "Rev 1" (with the
prefix baked in). Strip any leading "rev " so the wrapper doesn't
render "(Rev Rev 1)".
Express Orders override (2026-05-27): when called with the
`fp_express_part_picker=True` context, return JUST part_number.
The FpExpressPartCell OWL widget shows revision and part name on
their own rows, so the picker display should be just the bare
part number to avoid 'PART (Rev A) — NAME' duplicating with the
widget's separate rev / name rows.
"""
express = self.env.context.get('fp_express_part_picker')
for rec in self:
if express and rec.part_number:
rec.display_name = rec.part_number
continue
if rec.part_number:
core = f"{rec.part_number}"
if rec.revision:

View File

@@ -123,10 +123,14 @@ class SaleOrder(models.Model):
)
# ---- Express Orders header-level (2026-05-26) ----
x_fc_material_process = fields.Char(
# 2026-05-27: changed from Char to Many2One — Material/Process Tag
# IS the order's recipe. Auto-applies to every line at confirm time.
x_fc_material_process = fields.Many2one(
'fusion.plating.process.node',
string='Material / Process Tag',
help='Free-text order-level shop tag (e.g. ENP-STEEL-HP-ADVANCED). '
'Informational; not used by the workflow.',
domain="[('node_type', '=', 'recipe')]",
help='Order-level recipe — auto-applies to every line. Individual '
'lines can still override via x_fc_process_variant_id.',
)
x_fc_internal_notes = fields.Text(
string='Order-Level Internal Notes',

View File

@@ -140,9 +140,10 @@
</div>
<!-- Right-of-PO row 3 cols 3-4 — Material/Process Tag + Lead Time -->
<div class="o_fp_xpr_cell">
<label for="material_process">Material / Process Tag</label>
<label for="material_process">Material / Process</label>
<field name="material_process" nolabel="1"
placeholder="e.g. ENP-STEEL-HP-ADVANCED"/>
options="{'no_create_edit': True}"
placeholder="Pick a recipe..."/>
</div>
<div class="o_fp_xpr_cell">
<label for="lead_time_min_days">Lead Time (days)</label>
@@ -239,7 +240,7 @@
string="Part Number"
widget="fp_express_part_cell"
width="230px"
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A', 'fp_express_part_picker': True}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
options="{'no_quick_create': True}"/>
<!-- Hidden related fields the widget reads. Must be on the

View File

@@ -583,6 +583,97 @@ class FpDirectOrderLine(models.Model):
mask_default = getattr(part, 'default_masking_enabled', True)
rec.masking_enabled = mask_default
# ---- Express Orders: auto-sync line edits to fp.part.catalog ----
# Fields tracked for write-back. When any of these change on a wizard
# line, the corresponding default on the part is updated so future
# orders pre-fill from the latest values.
_FP_PART_SYNC_FIELDS = {
'line_description': 'default_specification_text',
'bake_instructions': 'default_bake_instructions',
'thickness_range': 'x_fc_default_thickness_range',
'masking_enabled': 'default_masking_enabled',
'process_variant_id': 'default_process_variant_id',
}
def _fp_sync_to_part(self):
"""Push tracked line fields back to the linked part's defaults.
Called from create + write. Last-write-wins semantics — if two
orders simultaneously edit the same part, the later one's values
become the part's defaults. Acceptable per dev-stage policy;
the part chatter records the change either way.
"""
for rec in self:
part = rec.part_catalog_id
if not part:
continue
wb_vals = {}
for line_field, part_field in self._FP_PART_SYNC_FIELDS.items():
if part_field not in part._fields:
continue
line_val = rec[line_field]
# For M2O, normalise to id
if hasattr(line_val, 'id'):
line_val = line_val.id
# Booleans always write; text fields only when non-empty
if isinstance(line_val, bool):
pass
elif not line_val:
continue
# Only write if the part's current value differs
current = part[part_field]
if hasattr(current, 'id'):
current = current.id
if current == line_val:
continue
wb_vals[part_field] = line_val
if wb_vals:
part.sudo().with_context(
fp_skip_chatter_spam=True,
).write(wb_vals)
@api.model_create_multi
def create(self, vals_list):
"""Auto-apply order-level recipe to new lines + sync to part on create.
Recipe propagation: when estimator adds a line to an order that
already has a Material/Process recipe picked at the header, the
line inherits that recipe automatically.
Part sync: any line values typed during create immediately
write back to the linked part's defaults.
"""
# 1. Recipe propagation from wizard.material_process
Wizard = self.env['fp.direct.order.wizard']
wizard_cache = {}
for vals in vals_list:
if vals.get('process_variant_id'):
continue
wiz_id = vals.get('wizard_id') or self.env.context.get('default_wizard_id')
if not wiz_id:
continue
if wiz_id not in wizard_cache:
wiz = Wizard.browse(wiz_id).exists()
wizard_cache[wiz_id] = (wiz.material_process.id
if wiz and wiz.material_process
else False)
recipe_id = wizard_cache[wiz_id]
if recipe_id:
vals['process_variant_id'] = recipe_id
# 2. Create the lines
records = super().create(vals_list)
# 3. Push to part defaults
records._fp_sync_to_part()
return records
def write(self, vals):
"""Auto-sync line edits to the linked part's defaults."""
res = super().write(vals)
sync_keys = set(self._FP_PART_SYNC_FIELDS.keys())
if sync_keys & set(vals.keys()) or 'part_catalog_id' in vals:
self._fp_sync_to_part()
return res
def action_open_part(self):
"""Open the linked fp.part.catalog form in a modal."""
self.ensure_one()

View File

@@ -165,6 +165,33 @@ class FpDirectOrderWizard(models.Model):
"""Default pricelist = company's default. Re-resolved on partner pick."""
return self.env.company.partner_id.property_product_pricelist.id or False
# ---- Express Orders: auto-apply order recipe to all lines ----
@api.onchange('material_process')
def _onchange_material_process_apply_to_lines(self):
"""When the order-level recipe is picked / changed, propagate it to
every line's process_variant_id so the operator doesn't have to
set the recipe line-by-line.
Lines that have explicitly overridden their recipe AFTER this
onchange last fired won't be clobbered — we only update lines
whose current process_variant_id is empty OR matches the PREVIOUS
material_process value (i.e. they inherited from the header and
haven't been customised).
"""
for rec in self:
new_recipe = rec.material_process
if not new_recipe:
continue
for line in rec.line_ids:
# Skip lines that have a different recipe explicitly set
# (i.e. the operator chose a per-line override).
if (line.process_variant_id
and line.process_variant_id != new_recipe
and line.process_variant_id
!= rec._origin.material_process):
continue
line.process_variant_id = new_recipe
# ---- View switching (Express ↔ Legacy) ----
def action_switch_to_express(self):
"""Re-open this draft in the Express view."""
@@ -255,10 +282,18 @@ class FpDirectOrderWizard(models.Model):
help='Visible only to the estimator / planner / shop. Never prints.',
)
# ---- Express Orders header (2026-05-26) ----
material_process = fields.Char(
# ---- Express Orders header (2026-05-26 / 2026-05-27 redesign) ----
# Material/Process Tag IS the recipe: when set on the order header,
# every line auto-uses this recipe (unless the line explicitly
# overrides via its own process_variant_id). Was a Char tag until
# 19.0.22.1.0 — converted to Many2One per customer feedback.
material_process = fields.Many2one(
'fusion.plating.process.node',
string='Material / Process Tag',
help='Free-text shop tag (e.g. ENP-STEEL-HP-ADVANCED). Informational.',
domain="[('node_type', '=', 'recipe')]",
help='Pick a recipe — applies automatically to every line on this '
'order. Individual lines can still override via their own '
'Process / Recipe column.',
)
validity_date = fields.Date(
string='Quote Validity',