diff --git a/fusion-plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion-plating/fusion_plating_bridge_mrp/__manifest__.py index b2ba84b4..8a0d6446 100644 --- a/fusion-plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion-plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — MRP Bridge', - 'version': '19.0.1.0.0', + 'version': '19.0.2.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ diff --git a/fusion-plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion-plating/fusion_plating_bridge_mrp/models/mrp_production.py index 3ee328af..8cd3ed07 100644 --- a/fusion-plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion-plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -3,9 +3,13 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +import logging + from odoo import api, fields, models, _ from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) + class MrpProduction(models.Model): """Extend manufacturing order with Fusion Plating references and @@ -68,10 +72,116 @@ class MrpProduction(models.Model): } # ------------------------------------------------------------------ - # GAP 2: SO confirm → MO confirm → auto-create Portal Job + # Recipe → Work Order generation + # ------------------------------------------------------------------ + def _generate_workorders_from_recipe(self): + """Generate mrp.workorder records from the assigned recipe. + + Walks the recipe tree, creates one WO per 'operation' node, + and formats child 'step' nodes as WO instructions. + Respects opt-in/out overrides from x_fc_override_ids. + """ + WorkOrder = self.env['mrp.workorder'] + for production in self: + if not production.x_fc_recipe_id: + continue # No recipe assigned + if production.workorder_ids: + continue # WOs already exist — don't duplicate + + # Build lookup of overrides keyed by node ID + override_map = {} # {node_id: included_bool} + for override in production.x_fc_override_ids: + override_map[override.node_id.id] = override.included + + # Walk tree and collect operation WO values + wo_vals_list = [] + seq_counter = [10] # mutable for closure, increments by 10 + + def _is_node_included(node): + """Determine if a node should be included based on opt-in/out + logic and per-job overrides. + + - disabled: always included (not configurable) + - opt_in: excluded by default, included only with override + - opt_out: included by default, excluded only with override + """ + nid = node.id + opt = node.opt_in_out or 'disabled' + if opt == 'disabled': + return True + if nid in override_map: + return override_map[nid] + # No override → use default + if opt == 'opt_in': + return False # Default excluded + # opt_out → default included + return True + + def walk_node(node): + if not _is_node_included(node): + return + + if node.node_type == 'operation': + # Map FP work centre → MRP work centre + mrp_wc = False + if node.work_center_id and node.work_center_id.x_fc_mrp_workcenter_id: + mrp_wc = node.work_center_id.x_fc_mrp_workcenter_id.id + if not mrp_wc: + _logger.warning( + 'MO %s: operation "%s" has no mapped MRP work centre — ' + 'skipping WO creation.', + production.name, node.name, + ) + # Still recurse into children for nested sub-operations + for child in node.child_ids.sorted('sequence'): + walk_node(child) + return + + # Collect step instructions from child 'step' nodes + steps = [] + 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 + steps.append(line) + step_num += 1 + + wo_vals_list.append({ + 'production_id': production.id, + 'name': node.name, + 'workcenter_id': mrp_wc, + 'duration_expected': node.estimated_duration or 0, + 'sequence': seq_counter[0], + 'description': '\n'.join(steps) if steps else '', + }) + seq_counter[0] += 10 + + elif node.node_type in ('recipe', 'sub_process'): + # Container nodes — recurse into children + for child in node.child_ids.sorted('sequence'): + walk_node(child) + # 'step' nodes at top level are handled by their parent operation + + # Start walking from recipe root + walk_node(production.x_fc_recipe_id) + + # Bulk create work orders + if wo_vals_list: + WorkOrder.create(wo_vals_list) + production.message_post( + body=_('%d work orders generated from recipe "%s".') % ( + len(wo_vals_list), production.x_fc_recipe_id.name), + ) + + # ------------------------------------------------------------------ + # GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs # ------------------------------------------------------------------ def action_confirm(self): - """Override to auto-create a portal job when the MO is confirmed.""" + """Override to auto-create a portal job and generate work orders + from the assigned recipe when the MO is confirmed. + """ res = super().action_confirm() PortalJob = self.env['fusion.plating.portal.job'] for mo in self: @@ -102,6 +212,10 @@ class MrpProduction(models.Model): 'company_id': mo.company_id.id, }) mo.x_fc_portal_job_id = job + + # Generate work orders from recipe (after portal job creation) + self._generate_workorders_from_recipe() + return res # ------------------------------------------------------------------