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:
@@ -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': """
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user