feat(jobs): add fp.job._generate_steps_from_recipe (Task 2.4)
Native port of fusion_plating_bridge_mrp's _generate_workorders_from_recipe method. Walks the recipe tree, creates one fp.job.step per 'operation' node, formats child 'step' nodes as step instructions on chatter, respects opt-in/out overrides from fp.job.node.override. Adaptations from the original: - Creates fp.job.step (not mrp.workorder) - Maps fusion.plating.work.center to fp.work.centre via forward link (x_fc_fp_work_centre_id) or code fallback - Uses native field names (job_id, work_centre_id, etc.) - Drops work_role_id (not on fp.job.step yet — Task 2.6+) - Drops _fp_autofill_default_equipment (not yet on step) 5 new tests cover: basic generation, idempotency, no-recipe skip, opt-in override behaviour, recipe_node_id link. Manifest 19.0.1.2.0 → 19.0.1.3.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.1.2.0',
|
||||
'version': '19.0.1.3.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'description': """
|
||||
|
||||
@@ -10,8 +10,14 @@
|
||||
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
|
||||
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
@@ -41,3 +47,196 @@ class FpJob(models.Model):
|
||||
'job_id',
|
||||
string='Recipe Overrides',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recipe → fp.job.step generation (Task 2.4)
|
||||
#
|
||||
# Native port of fusion_plating_bridge_mrp's
|
||||
# _generate_workorders_from_recipe. Walks the recipe tree, creates
|
||||
# one fp.job.step per 'operation' node, formats child 'step' nodes
|
||||
# as step instructions on chatter, respects opt-in/out overrides
|
||||
# from fp.job.node.override.
|
||||
#
|
||||
# Adaptations from the original:
|
||||
# - Creates fp.job.step (not mrp.workorder)
|
||||
# - Maps fusion.plating.work.center → fp.work.centre via code
|
||||
# fallback (no forward link exists yet)
|
||||
# - Uses native field names (job_id, work_centre_id, etc.)
|
||||
# - Drops work_role_id (not on fp.job.step yet — Task 2.6+)
|
||||
# - Drops _fp_autofill_default_equipment (not yet on step)
|
||||
# ------------------------------------------------------------------
|
||||
def _generate_steps_from_recipe(self):
|
||||
"""Generate fp.job.step records from the assigned recipe.
|
||||
|
||||
Walks the recipe tree, creates one step per 'operation' node,
|
||||
and formats child 'step' nodes as step instructions on the
|
||||
chatter. Respects opt-in/out overrides from override_ids.
|
||||
"""
|
||||
Step = self.env['fp.job.step']
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
for job in self:
|
||||
if not job.recipe_id:
|
||||
continue # No recipe assigned
|
||||
if job.step_ids:
|
||||
continue # Steps already exist — don't duplicate
|
||||
|
||||
# Build lookup of overrides keyed by node ID
|
||||
override_map = {ov.node_id.id: ov.included for ov in job.override_ids}
|
||||
|
||||
# Start-at-node: if set, the allowed set is the union of:
|
||||
# 1. start_node and all its descendants
|
||||
# 2. each ancestor of start_node
|
||||
# 3. at each ancestor level, any LATER-sequence sibling and
|
||||
# all of its descendants
|
||||
start_node = job.start_at_node_id
|
||||
allowed_ids = None # None = include everything
|
||||
if start_node:
|
||||
descendants = Node.search([('id', 'child_of', start_node.id)])
|
||||
allowed_ids = set(descendants.ids)
|
||||
cur = start_node
|
||||
while cur.parent_id:
|
||||
parent = cur.parent_id
|
||||
allowed_ids.add(parent.id)
|
||||
later_sibs = parent.child_ids.filtered(
|
||||
lambda n: n.sequence > cur.sequence
|
||||
)
|
||||
for sib in later_sibs:
|
||||
sib_descendants = Node.search([
|
||||
('id', 'child_of', sib.id),
|
||||
])
|
||||
allowed_ids |= set(sib_descendants.ids)
|
||||
cur = parent
|
||||
|
||||
step_vals_list = []
|
||||
wo_steps = {} # {sequence: instruction text}
|
||||
seq_counter = [10]
|
||||
|
||||
def _is_node_included(node):
|
||||
"""Determine if a node should be included based on
|
||||
opt-in/out logic, per-job overrides, and start-at-node
|
||||
filter.
|
||||
"""
|
||||
nid = node.id
|
||||
if allowed_ids is not None and nid not in allowed_ids:
|
||||
return False
|
||||
opt = node.opt_in_out or 'disabled'
|
||||
if opt == 'disabled':
|
||||
return True
|
||||
if nid in override_map:
|
||||
return override_map[nid]
|
||||
if opt == 'opt_in':
|
||||
return False # Default excluded
|
||||
return True # opt_out → default included
|
||||
|
||||
def _resolve_work_centre(legacy_wc):
|
||||
"""Map fusion.plating.work.center → fp.work.centre.
|
||||
|
||||
The legacy work-centre model does not (yet) have a forward
|
||||
link to the new fp.work.centre. Try a forward link
|
||||
(x_fc_fp_work_centre_id) if some bridge module added one;
|
||||
otherwise fall back to a code lookup.
|
||||
"""
|
||||
if not legacy_wc:
|
||||
return self.env['fp.work.centre']
|
||||
# Forward link, if any
|
||||
if (
|
||||
'x_fc_fp_work_centre_id' in legacy_wc._fields
|
||||
and legacy_wc.x_fc_fp_work_centre_id
|
||||
):
|
||||
return legacy_wc.x_fc_fp_work_centre_id
|
||||
# Code fallback (legacy code is unique-per-facility,
|
||||
# native code is globally unique — first match wins)
|
||||
if legacy_wc.code:
|
||||
found = self.env['fp.work.centre'].search(
|
||||
[('code', '=', legacy_wc.code)], limit=1,
|
||||
)
|
||||
if found:
|
||||
return found
|
||||
return self.env['fp.work.centre']
|
||||
|
||||
def walk_node(node):
|
||||
if not _is_node_included(node):
|
||||
return
|
||||
|
||||
if node.node_type == 'operation':
|
||||
work_centre = _resolve_work_centre(node.work_center_id)
|
||||
if not work_centre:
|
||||
_logger.warning(
|
||||
'Job %s: operation "%s" has no mapped fp.work.centre — '
|
||||
'creating step without work centre.',
|
||||
job.name, node.name,
|
||||
)
|
||||
|
||||
# Collect step instructions from child 'step' nodes
|
||||
instructions = []
|
||||
step_num = 1
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
if child.node_type == 'step' and _is_node_included(child):
|
||||
line = '%d. %s' % (step_num, child.name)
|
||||
if child.estimated_duration:
|
||||
line += ' (%.0f min)' % child.estimated_duration
|
||||
instructions.append(line)
|
||||
step_num += 1
|
||||
|
||||
vals = {
|
||||
'job_id': job.id,
|
||||
'name': node.name,
|
||||
'work_centre_id': work_centre.id if work_centre else False,
|
||||
'duration_expected': node.estimated_duration or 0.0,
|
||||
'sequence': seq_counter[0],
|
||||
'recipe_node_id': node.id,
|
||||
}
|
||||
if node.estimated_duration:
|
||||
vals['dwell_time_minutes'] = node.estimated_duration
|
||||
|
||||
# Pull thickness target from the coating config when
|
||||
# this is a plating step (matched by node name keyword).
|
||||
coating = job.coating_config_id
|
||||
name_l = (node.name or '').lower()
|
||||
is_plating_node = (
|
||||
'plat' in name_l or 'nickel' in name_l
|
||||
or 'chrome' in name_l or 'anodiz' in name_l
|
||||
)
|
||||
if coating and is_plating_node:
|
||||
if (
|
||||
'thickness_max' in coating._fields
|
||||
and coating.thickness_max
|
||||
):
|
||||
vals['thickness_target'] = coating.thickness_max
|
||||
if (
|
||||
'thickness_uom' in coating._fields
|
||||
and coating.thickness_uom
|
||||
):
|
||||
vals['thickness_uom'] = coating.thickness_uom
|
||||
|
||||
step_vals_list.append(vals)
|
||||
if instructions:
|
||||
wo_steps[seq_counter[0]] = '\n'.join(instructions)
|
||||
seq_counter[0] += 10
|
||||
|
||||
elif node.node_type in ('recipe', 'sub_process'):
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
walk_node(child)
|
||||
# 'step' nodes at top level are handled by their parent operation
|
||||
|
||||
# Walk from recipe root
|
||||
walk_node(job.recipe_id)
|
||||
|
||||
# Bulk create
|
||||
if step_vals_list:
|
||||
created = Step.create(step_vals_list)
|
||||
for step in created:
|
||||
instr_text = wo_steps.get(step.sequence)
|
||||
if instr_text:
|
||||
step.message_post(
|
||||
body=Markup(
|
||||
'<b>Recipe steps:</b><br/><pre>%s</pre>'
|
||||
) % instr_text,
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
job.message_post(
|
||||
body=('%d steps generated from recipe "%s".') % (
|
||||
len(step_vals_list), job.recipe_id.name,
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -154,3 +154,109 @@ class TestFpJobNodeOverride(TransactionCase):
|
||||
})
|
||||
self.job.invalidate_recordset(['override_ids'])
|
||||
self.assertIn(ovr, self.job.override_ids)
|
||||
|
||||
|
||||
class TestFpJobStepsGenerator(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'C'})
|
||||
self.product = self.env['product.product'].create({'name': 'W'})
|
||||
self.wc = self.env['fp.work.centre'].create({
|
||||
'name': 'Bath', 'code': 'BATH', 'kind': 'wet_line',
|
||||
})
|
||||
# Build a simple recipe: recipe → 2 operations + 1 opt-in op
|
||||
self.recipe = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'TestRecipe',
|
||||
'node_type': 'recipe',
|
||||
})
|
||||
# Legacy work centre (recipe nodes still point at the legacy model).
|
||||
# Match the new fp.work.centre.code so the resolver picks it up.
|
||||
facility = self.env['fusion.plating.facility'].search([], limit=1)
|
||||
if not facility:
|
||||
facility = self.env['fusion.plating.facility'].create({
|
||||
'name': 'TestFacility',
|
||||
'code': 'TF',
|
||||
})
|
||||
legacy_wc = self.env['fusion.plating.work.center'].search(
|
||||
[('code', '=', 'BATH')], limit=1)
|
||||
if not legacy_wc:
|
||||
legacy_wc = self.env['fusion.plating.work.center'].create({
|
||||
'name': 'Bath',
|
||||
'code': 'BATH',
|
||||
'facility_id': facility.id,
|
||||
})
|
||||
self.legacy_wc = legacy_wc
|
||||
self.op1 = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Plating Bath',
|
||||
'node_type': 'operation',
|
||||
'parent_id': self.recipe.id,
|
||||
'sequence': 10,
|
||||
'estimated_duration': 30.0,
|
||||
'work_center_id': self.legacy_wc.id,
|
||||
})
|
||||
self.op2 = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Bake',
|
||||
'node_type': 'operation',
|
||||
'parent_id': self.recipe.id,
|
||||
'sequence': 20,
|
||||
'estimated_duration': 60.0,
|
||||
})
|
||||
self.opt_in = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Optional Inspect',
|
||||
'node_type': 'operation',
|
||||
'parent_id': self.recipe.id,
|
||||
'sequence': 30,
|
||||
'opt_in_out': 'opt_in',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'recipe_id': self.recipe.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
def test_generator_creates_steps(self):
|
||||
job = self._make_job()
|
||||
job._generate_steps_from_recipe()
|
||||
# 2 ops by default; opt_in skipped without an override
|
||||
self.assertEqual(len(job.step_ids), 2)
|
||||
|
||||
def test_generator_idempotent(self):
|
||||
job = self._make_job()
|
||||
job._generate_steps_from_recipe()
|
||||
first_count = len(job.step_ids)
|
||||
job._generate_steps_from_recipe()
|
||||
self.assertEqual(len(job.step_ids), first_count)
|
||||
|
||||
def test_generator_skips_no_recipe(self):
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
})
|
||||
job._generate_steps_from_recipe()
|
||||
self.assertFalse(job.step_ids)
|
||||
|
||||
def test_generator_respects_opt_in_override(self):
|
||||
job = self._make_job()
|
||||
self.env['fp.job.node.override'].create({
|
||||
'job_id': job.id,
|
||||
'node_id': self.opt_in.id,
|
||||
'included': True,
|
||||
})
|
||||
job._generate_steps_from_recipe()
|
||||
# 3 steps: 2 default + 1 opted-in
|
||||
self.assertEqual(len(job.step_ids), 3)
|
||||
|
||||
def test_generator_recipe_node_link(self):
|
||||
job = self._make_job()
|
||||
job._generate_steps_from_recipe()
|
||||
first_step = job.step_ids.sorted('sequence')[0]
|
||||
self.assertEqual(first_step.recipe_node_id, self.op1)
|
||||
self.assertEqual(first_step.duration_expected, 30.0)
|
||||
# Work centre resolved by code from legacy model
|
||||
self.assertEqual(first_step.work_centre_id, self.wc)
|
||||
|
||||
Reference in New Issue
Block a user