fusion_plating_bridge_mrp: per-job recipe overrides with config wizard (v19.0.2.0.0)
Links recipes to manufacturing orders via x_fc_recipe_id on mrp.production. New model fusion.plating.job.node.override stores per-job opt-in/out decisions for optional recipe steps. Config wizard (fp.recipe.config.wizard) shows all optional nodes as a checklist — opt-in steps default unchecked, opt-out steps default checked. Planner toggles and confirms. "Overrides" stat button on MO form opens wizard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@@ -47,6 +47,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/fp_recipe_config_wizard_views.xml',
|
||||
'views/mrp_workcenter_views.xml',
|
||||
'views/mrp_workorder_views.xml',
|
||||
'views/mrp_production_views.xml',
|
||||
|
||||
@@ -11,4 +11,5 @@ from . import fp_portal_job
|
||||
from . import fp_quality_hold
|
||||
from . import fp_delivery
|
||||
from . import fp_batch
|
||||
from . import fp_job_node_override
|
||||
from . import account_move
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpJobNodeOverride(models.Model):
|
||||
"""Per-job override for optional recipe steps.
|
||||
|
||||
When a recipe is assigned to a manufacturing order, nodes with
|
||||
opt_in_out != 'disabled' can be toggled on or off for that specific
|
||||
job. Opt-in nodes default to excluded; opt-out nodes default to
|
||||
included. The planner changes these via the configuration wizard.
|
||||
"""
|
||||
_name = 'fusion.plating.job.node.override'
|
||||
_description = 'Fusion Plating — Job Node Override'
|
||||
_order = 'node_sequence, id'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production',
|
||||
string='Manufacturing Order',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Step',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
node_name = fields.Char(
|
||||
related='node_id.name',
|
||||
string='Step Name',
|
||||
readonly=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
related='node_id.node_type',
|
||||
string='Type',
|
||||
readonly=True,
|
||||
)
|
||||
node_sequence = fields.Integer(
|
||||
related='node_id.sequence',
|
||||
string='Sequence',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
related='node_id.opt_in_out',
|
||||
string='Default',
|
||||
readonly=True,
|
||||
)
|
||||
included = fields.Boolean(
|
||||
string='Included',
|
||||
default=True,
|
||||
help='Whether this optional step is active for this job.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_production_node',
|
||||
'unique(production_id, node_id)',
|
||||
'Each recipe step can only have one override per job.'),
|
||||
]
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class MrpProduction(models.Model):
|
||||
@@ -27,6 +28,44 @@ class MrpProduction(models.Model):
|
||||
string='Portal Job',
|
||||
help='The portal job linked to this manufacturing order.',
|
||||
)
|
||||
x_fc_recipe_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe',
|
||||
domain=[('node_type', '=', 'recipe')],
|
||||
help='Process recipe template for this manufacturing order.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_override_ids = fields.One2many(
|
||||
'fusion.plating.job.node.override',
|
||||
'production_id',
|
||||
string='Recipe Overrides',
|
||||
)
|
||||
x_fc_override_count = fields.Integer(
|
||||
string='Overrides',
|
||||
compute='_compute_override_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_override_ids')
|
||||
def _compute_override_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_override_count = len(rec.x_fc_override_ids)
|
||||
|
||||
def action_configure_recipe_steps(self):
|
||||
"""Open the wizard to configure opt-in/out steps for this job."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_recipe_id:
|
||||
raise UserError(_('Please select a recipe first.'))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Configure Steps — {self.x_fc_recipe_id.name}',
|
||||
'res_model': 'fp.recipe.config.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_production_id': self.id,
|
||||
'default_recipe_id': self.x_fc_recipe_id.id,
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 2: SO confirm → MO confirm → auto-create Portal Job
|
||||
|
||||
@@ -5,3 +5,10 @@ access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_worko
|
||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -21,10 +21,20 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_portal_job_id"/>
|
||||
<field name="x_fc_recipe_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_configure_recipe_steps" type="object"
|
||||
class="oe_stat_button" icon="fa-sliders"
|
||||
invisible="not x_fc_recipe_id">
|
||||
<field name="x_fc_override_count" widget="statinfo"
|
||||
string="Overrides"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import fp_recipe_config_wizard
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpRecipeConfigWizard(models.TransientModel):
|
||||
"""Wizard to configure which optional recipe steps are included
|
||||
for a specific manufacturing order.
|
||||
|
||||
Shows all nodes where opt_in_out != 'disabled' as a checklist.
|
||||
Opt-in nodes default unchecked (skipped), opt-out nodes default
|
||||
checked (included). On confirm, creates or updates override records.
|
||||
"""
|
||||
_name = 'fp.recipe.config.wizard'
|
||||
_description = 'Configure Recipe Steps'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production',
|
||||
string='Manufacturing Order',
|
||||
required=True,
|
||||
)
|
||||
recipe_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe',
|
||||
required=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.recipe.config.wizard.line',
|
||||
'wizard_id',
|
||||
string='Optional Steps',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
production_id = res.get('production_id') or self.env.context.get('default_production_id')
|
||||
recipe_id = res.get('recipe_id') or self.env.context.get('default_recipe_id')
|
||||
if not production_id or not recipe_id:
|
||||
return res
|
||||
|
||||
production = self.env['mrp.production'].browse(production_id)
|
||||
recipe = self.env['fusion.plating.process.node'].browse(recipe_id)
|
||||
|
||||
# Collect all optional nodes (recursive)
|
||||
optional_nodes = self._get_optional_nodes(recipe)
|
||||
if not optional_nodes:
|
||||
return res
|
||||
|
||||
# Check for existing overrides
|
||||
existing = {
|
||||
ov.node_id.id: ov.included
|
||||
for ov in production.x_fc_override_ids
|
||||
}
|
||||
|
||||
lines = []
|
||||
for node in optional_nodes:
|
||||
if node.id in existing:
|
||||
included = existing[node.id]
|
||||
else:
|
||||
# Default: opt-in → False (skipped), opt-out → True (included)
|
||||
included = node.opt_in_out == 'opt_out'
|
||||
lines.append((0, 0, {
|
||||
'node_id': node.id,
|
||||
'included': included,
|
||||
}))
|
||||
|
||||
res['line_ids'] = lines
|
||||
return res
|
||||
|
||||
def _get_optional_nodes(self, node):
|
||||
"""Recursively collect all nodes with opt_in_out != 'disabled'."""
|
||||
result = []
|
||||
if node.opt_in_out and node.opt_in_out != 'disabled':
|
||||
result.append(node)
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
result.extend(self._get_optional_nodes(child))
|
||||
return result
|
||||
|
||||
def action_confirm(self):
|
||||
"""Save overrides and close wizard."""
|
||||
self.ensure_one()
|
||||
Override = self.env['fusion.plating.job.node.override']
|
||||
production = self.production_id
|
||||
|
||||
# Delete existing overrides for this MO and recreate
|
||||
production.x_fc_override_ids.unlink()
|
||||
|
||||
for line in self.line_ids:
|
||||
Override.create({
|
||||
'production_id': production.id,
|
||||
'node_id': line.node_id.id,
|
||||
'included': line.included,
|
||||
})
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class FpRecipeConfigWizardLine(models.TransientModel):
|
||||
"""One line in the recipe config wizard — an optional step."""
|
||||
_name = 'fp.recipe.config.wizard.line'
|
||||
_description = 'Recipe Config Wizard Line'
|
||||
_order = 'node_sequence, id'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.recipe.config.wizard',
|
||||
string='Wizard',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Step',
|
||||
required=True,
|
||||
)
|
||||
node_name = fields.Char(
|
||||
related='node_id.name',
|
||||
string='Step Name',
|
||||
readonly=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
related='node_id.node_type',
|
||||
string='Type',
|
||||
readonly=True,
|
||||
)
|
||||
node_sequence = fields.Integer(
|
||||
related='node_id.sequence',
|
||||
string='Seq',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
related='node_id.opt_in_out',
|
||||
string='Default',
|
||||
readonly=True,
|
||||
)
|
||||
included = fields.Boolean(
|
||||
string='Include in Job',
|
||||
default=True,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_recipe_config_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.recipe.config.wizard.form</field>
|
||||
<field name="model">fp.recipe.config.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Configure Recipe Steps">
|
||||
<group>
|
||||
<field name="production_id" readonly="True"/>
|
||||
<field name="recipe_id" readonly="True"/>
|
||||
</group>
|
||||
<separator string="Optional Steps"/>
|
||||
<p class="text-muted">
|
||||
Toggle which optional steps are included for this job.
|
||||
<strong>Opt-In</strong> steps are skipped by default — check to include.
|
||||
<strong>Opt-Out</strong> steps are included by default — uncheck to skip.
|
||||
</p>
|
||||
<field name="line_ids">
|
||||
<list editable="bottom" no_open="True">
|
||||
<field name="node_name" string="Step"/>
|
||||
<field name="node_type" widget="badge"
|
||||
decoration-success="node_type == 'operation'"
|
||||
decoration-warning="node_type == 'sub_process'"
|
||||
decoration-muted="node_type == 'step'"/>
|
||||
<field name="opt_in_out" widget="badge"
|
||||
decoration-info="opt_in_out == 'opt_in'"
|
||||
decoration-warning="opt_in_out == 'opt_out'"/>
|
||||
<field name="included" widget="boolean_toggle"/>
|
||||
<field name="node_id" column_invisible="True"/>
|
||||
<field name="node_sequence" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm" class="btn-primary"/>
|
||||
<button string="Cancel" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user