# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # fp.job extension — cross-module fields that couldn't live in core # because their target models are in dependent modules. Per spec §5.1 # this module is the umbrella that re-bundles the cross-module # extensions for the native job flow. # # 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' part_catalog_id = fields.Many2one( 'fp.part.catalog', string='Part', ) coating_config_id = fields.Many2one( 'fp.coating.config', string='Coating Configuration', ) customer_spec_id = fields.Many2one( 'fusion.plating.customer.spec', string='Customer Spec', ) portal_job_id = fields.Many2one( 'fusion.plating.portal.job', string='Portal Job', ) delivery_id = fields.Many2one( 'fusion.plating.delivery', string='Delivery', ) override_ids = fields.One2many( 'fp.job.node.override', '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