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)