diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py
index 1e8ea381..b109ae77 100644
--- a/fusion_plating/fusion_plating_jobs/__manifest__.py
+++ b/fusion_plating/fusion_plating_jobs/__manifest__.py
@@ -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': """
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py
index dde70dab..14166c21 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py
@@ -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(
+ 'Recipe steps:
%s' + ) % 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 diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py index ef3ad7bc..e0dc2346 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py @@ -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)