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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user