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',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.22.0.0',
|
'version': '19.0.22.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'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)
|
part.description_template_count = len(part.description_template_ids)
|
||||||
|
|
||||||
@api.depends('part_number', 'revision', 'name')
|
@api.depends('part_number', 'revision', 'name')
|
||||||
|
@api.depends_context('fp_express_part_picker')
|
||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
"""Display = 'PART-NUMBER (Rev X) — Optional Name'.
|
"""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
|
Defensive: some legacy rows stored revision as "Rev 1" (with the
|
||||||
prefix baked in). Strip any leading "rev " so the wrapper doesn't
|
prefix baked in). Strip any leading "rev " so the wrapper doesn't
|
||||||
render "(Rev Rev 1)".
|
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:
|
for rec in self:
|
||||||
|
if express and rec.part_number:
|
||||||
|
rec.display_name = rec.part_number
|
||||||
|
continue
|
||||||
if rec.part_number:
|
if rec.part_number:
|
||||||
core = f"{rec.part_number}"
|
core = f"{rec.part_number}"
|
||||||
if rec.revision:
|
if rec.revision:
|
||||||
|
|||||||
@@ -123,10 +123,14 @@ class SaleOrder(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---- Express Orders header-level (2026-05-26) ----
|
# ---- 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',
|
string='Material / Process Tag',
|
||||||
help='Free-text order-level shop tag (e.g. ENP-STEEL-HP-ADVANCED). '
|
domain="[('node_type', '=', 'recipe')]",
|
||||||
'Informational; not used by the workflow.',
|
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(
|
x_fc_internal_notes = fields.Text(
|
||||||
string='Order-Level Internal Notes',
|
string='Order-Level Internal Notes',
|
||||||
|
|||||||
@@ -140,9 +140,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Right-of-PO row 3 cols 3-4 — Material/Process Tag + Lead Time -->
|
<!-- Right-of-PO row 3 cols 3-4 — Material/Process Tag + Lead Time -->
|
||||||
<div class="o_fp_xpr_cell">
|
<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"
|
<field name="material_process" nolabel="1"
|
||||||
placeholder="e.g. ENP-STEEL-HP-ADVANCED"/>
|
options="{'no_create_edit': True}"
|
||||||
|
placeholder="Pick a recipe..."/>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_xpr_cell">
|
<div class="o_fp_xpr_cell">
|
||||||
<label for="lead_time_min_days">Lead Time (days)</label>
|
<label for="lead_time_min_days">Lead Time (days)</label>
|
||||||
@@ -239,7 +240,7 @@
|
|||||||
string="Part Number"
|
string="Part Number"
|
||||||
widget="fp_express_part_cell"
|
widget="fp_express_part_cell"
|
||||||
width="230px"
|
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)]"
|
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
|
||||||
options="{'no_quick_create': True}"/>
|
options="{'no_quick_create': True}"/>
|
||||||
<!-- Hidden related fields the widget reads. Must be on the
|
<!-- 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)
|
mask_default = getattr(part, 'default_masking_enabled', True)
|
||||||
rec.masking_enabled = mask_default
|
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):
|
def action_open_part(self):
|
||||||
"""Open the linked fp.part.catalog form in a modal."""
|
"""Open the linked fp.part.catalog form in a modal."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -165,6 +165,33 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
"""Default pricelist = company's default. Re-resolved on partner pick."""
|
"""Default pricelist = company's default. Re-resolved on partner pick."""
|
||||||
return self.env.company.partner_id.property_product_pricelist.id or False
|
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) ----
|
# ---- View switching (Express ↔ Legacy) ----
|
||||||
def action_switch_to_express(self):
|
def action_switch_to_express(self):
|
||||||
"""Re-open this draft in the Express view."""
|
"""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.',
|
help='Visible only to the estimator / planner / shop. Never prints.',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Express Orders header (2026-05-26) ----
|
# ---- Express Orders header (2026-05-26 / 2026-05-27 redesign) ----
|
||||||
material_process = fields.Char(
|
# 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',
|
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(
|
validity_date = fields.Date(
|
||||||
string='Quote Validity',
|
string='Quote Validity',
|
||||||
|
|||||||
Reference in New Issue
Block a user