# -*- 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 api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FpJob(models.Model): _inherit = 'fp.job' part_catalog_id = fields.Many2one( 'fp.part.catalog', string='Part', ondelete='restrict', ) coating_config_id = fields.Many2one( 'fp.coating.config', string='Coating Configuration', ondelete='restrict', ) customer_spec_id = fields.Many2one( 'fusion.plating.customer.spec', string='Customer Spec', ondelete='set null', ) portal_job_id = fields.Many2one( 'fusion.plating.portal.job', string='Portal Job', ondelete='set null', ) delivery_id = fields.Many2one( 'fusion.plating.delivery', string='Delivery', ondelete='set null', ) override_ids = fields.One2many( 'fp.job.node.override', 'job_id', string='Recipe Overrides', ) # Phase 7 — migration idempotency key. Populated by # scripts/migrate_to_fp_jobs.py to mark a fp.job as the mirror of a # specific mrp.production. Used to skip already-migrated MOs on # subsequent runs. Cleared after the 2-week shadow period. legacy_mrp_production_id = fields.Integer( string='Legacy MRP Production ID', index=True, help='Database id of the source mrp.production record this job ' 'was migrated from. Used by the migration script for ' 'idempotency. Cleared post-cutover.', ) # ------------------------------------------------------------------ # Smart-button counts (Feature A — operator workflow) # # Compute counts for each downstream model so the form view can # render an oe_stat_button row similar to sale.order. Cross-module # models are runtime-detected so this still works when one of the # bridge modules is uninstalled. # ------------------------------------------------------------------ sale_order_count = fields.Integer(compute='_compute_smart_counts') delivery_count = fields.Integer(compute='_compute_smart_counts') invoice_count = fields.Integer(compute='_compute_smart_counts') payment_count = fields.Integer(compute='_compute_smart_counts') quality_hold_count = fields.Integer(compute='_compute_smart_counts') certificate_count = fields.Integer(compute='_compute_smart_counts') timelog_count = fields.Integer(compute='_compute_smart_counts') portal_job_count = fields.Integer(compute='_compute_smart_counts') @api.depends( 'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids', 'step_ids.time_log_ids', 'origin', 'partner_id', ) def _compute_smart_counts(self): AccountMove = self.env.get('account.move') AccountPayment = self.env.get('account.payment') QualityHold = self.env.get('fusion.plating.quality.hold') Certificate = self.env.get('fp.certificate') for job in self: job.sale_order_count = 1 if job.sale_order_id else 0 job.delivery_count = 1 if job.delivery_id else 0 job.portal_job_count = 1 if job.portal_job_id else 0 # Invoices via origin (the SO name) if AccountMove is not None and job.origin: job.invoice_count = AccountMove.search_count([ ('invoice_origin', '=', job.origin), ('move_type', 'in', ('out_invoice', 'out_refund')), ]) else: job.invoice_count = 0 # Payments — find invoices for this SO, then payments # reconciled against them. if (AccountMove is not None and AccountPayment is not None and job.origin): inv_ids = AccountMove.search([ ('invoice_origin', '=', job.origin), ('move_type', 'in', ('out_invoice', 'out_refund')), ]).ids if inv_ids: job.payment_count = AccountPayment.search_count([ ('reconciled_invoice_ids', 'in', inv_ids), ]) else: job.payment_count = 0 else: job.payment_count = 0 if QualityHold is not None: job.quality_hold_count = QualityHold.search_count([ ('x_fc_job_id', '=', job.id), ]) else: job.quality_hold_count = 0 if Certificate is not None: job.certificate_count = Certificate.search_count([ ('x_fc_job_id', '=', job.id), ]) else: job.certificate_count = 0 job.timelog_count = sum( len(s.time_log_ids) for s in job.step_ids ) # ------------------------------------------------------------------ # Smart-button actions # ------------------------------------------------------------------ def action_view_sale_order(self): self.ensure_one() if not self.sale_order_id: return {'type': 'ir.actions.act_window_close'} return { 'type': 'ir.actions.act_window', 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, 'view_mode': 'form', 'name': self.sale_order_id.name, } def action_view_steps(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'fp.job.step', 'view_mode': 'list,form', 'domain': [('job_id', '=', self.id)], 'name': 'Steps — %s' % self.name, 'context': {'default_job_id': self.id}, } def action_view_deliveries(self): self.ensure_one() if not self.delivery_id: return {'type': 'ir.actions.act_window_close'} return { 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.delivery', 'res_id': self.delivery_id.id, 'view_mode': 'form', 'name': self.delivery_id.name, } def action_view_invoices(self): self.ensure_one() if not self.origin: return {'type': 'ir.actions.act_window_close'} return { 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [ ('invoice_origin', '=', self.origin), ('move_type', 'in', ('out_invoice', 'out_refund')), ], 'name': 'Invoices — %s' % self.name, } def action_view_payments(self): self.ensure_one() if not self.origin: return {'type': 'ir.actions.act_window_close'} AccountMove = self.env.get('account.move') if AccountMove is None: return {'type': 'ir.actions.act_window_close'} inv_ids = AccountMove.search([ ('invoice_origin', '=', self.origin), ('move_type', 'in', ('out_invoice', 'out_refund')), ]).ids return { 'type': 'ir.actions.act_window', 'res_model': 'account.payment', 'view_mode': 'list,form', 'domain': ( [('reconciled_invoice_ids', 'in', inv_ids)] if inv_ids else [('id', '=', 0)] ), 'name': 'Payments — %s' % self.name, } def action_view_quality_holds(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.quality.hold', 'view_mode': 'list,form', 'domain': [('x_fc_job_id', '=', self.id)], 'name': 'Quality Holds — %s' % self.name, 'context': {'default_x_fc_job_id': self.id}, } def action_view_certificates(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'fp.certificate', 'view_mode': 'list,form', 'domain': [('x_fc_job_id', '=', self.id)], 'name': 'Certificates — %s' % self.name, 'context': {'default_x_fc_job_id': self.id}, } def action_view_timelogs(self): self.ensure_one() step_ids = self.step_ids.ids return { 'type': 'ir.actions.act_window', 'res_model': 'fp.job.step.timelog', 'view_mode': 'list,form', 'domain': ( [('step_id', 'in', step_ids)] if step_ids else [('id', '=', 0)] ), 'name': 'Time Logs — %s' % self.name, } def action_view_portal_job(self): self.ensure_one() if not self.portal_job_id: return {'type': 'ir.actions.act_window_close'} return { 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.portal.job', 'res_id': self.portal_job_id.id, 'view_mode': 'form', 'name': self.portal_job_id.name, } # ------------------------------------------------------------------ # 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 ): # fp.coating.config uses long-form uom names # (mils / microns / inches); fp.job.step uses # short codes (mil / um / inch). Map between # them. Unknown values fall through to the # step's default ('um'). _UOM_MAP = { 'mils': 'mil', 'mil': 'mil', 'microns': 'um', 'micron': 'um', 'um': 'um', 'inches': 'inch', 'inch': 'inch', 'in': 'inch', } mapped = _UOM_MAP.get(coating.thickness_uom) if mapped: vals['thickness_uom'] = mapped 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 # ------------------------------------------------------------------ # UI — Process Tree client action (Phase 6) # ------------------------------------------------------------------ def action_open_process_tree(self): """Open the OWL process-tree visualization for this job. Launches the fp_process_tree client action (defined in fusion_plating_shopfloor) with job_id in context. The component fetches /fp/shopfloor/process_tree and renders the recipe -> sub_process -> operation hierarchy as cards with per-step state badges. Consolidated 2026-04-24: this points at the canonical shopfloor client action; the parallel fp_job_process_tree was removed. """ self.ensure_one() return { 'type': 'ir.actions.client', 'tag': 'fp_process_tree', 'context': {'job_id': self.id}, 'name': 'Process Tree — %s' % (self.name or ''), 'target': 'current', } # ------------------------------------------------------------------ # Lifecycle hooks (Tasks 2.6, 2.7, 2.8) # # On confirm: create the portal-job mirror record and (when the # customer requires QC) a fusion.plating.quality.check. # On done: create a draft fusion.plating.delivery and best-effort # trigger fp.certificate auto-generation. # # The QC and certificate models live in modules this module does NOT # depend on by design (bridge_mrp). We runtime-detect those models so # the hooks degrade gracefully when those modules are absent. # ------------------------------------------------------------------ def action_confirm(self): result = super().action_confirm() # During migration, lifecycle side-effects are skipped — the # migration script directly rebinds existing portal/QC/inspection # records via x_fc_job_id. See scripts/migrate_to_fp_jobs.py. if self.env.context.get('fp_jobs_migration'): return result for job in self: job._fp_create_portal_job() job._fp_create_qc_check_if_needed() job._fp_create_racking_inspection() job._fp_fire_notification('job_confirmed') return result def _fp_create_racking_inspection(self): """Auto-create a draft racking inspection on job confirm. Mirrors bridge_mrp's behaviour for MO confirm. Best-effort: the legacy fp.racking.inspection model still requires a production_id (mrp.production), so we can only create one when this job is bound to an MO via bridge_mrp. Otherwise we skip cleanly — Phase 9 will flip the required-FK to fp.job. """ self.ensure_one() if 'fp.racking.inspection' not in self.env: return Inspection = self.env['fp.racking.inspection'].sudo() # The model still requires production_id today. If the job has # no MO link (which it won't in pure-native mode), skip rather # than crash. The link exists when fusion_plating_bridge_mrp is # installed and a production was created in parallel. production = False if 'production_id' in self._fields and self.production_id: production = self.production_id elif 'mrp_production_id' in self._fields and getattr( self, 'mrp_production_id', False): production = self.mrp_production_id if not production: _logger.debug( "Job %s: no MO link — skipping racking-inspection auto-create " "(required production_id not yet on fp.job).", self.name, ) return try: vals = {'production_id': production.id} if 'x_fc_job_id' in Inspection._fields: vals['x_fc_job_id'] = self.id Inspection.create(vals) except Exception as e: _logger.warning( "Job %s: failed to auto-create racking inspection: %s", self.name, e, ) def _fp_create_portal_job(self): """Create the fusion.plating.portal.job mirror record.""" self.ensure_one() if self.portal_job_id: return # already exists — idempotent Portal = self.env['fusion.plating.portal.job'].sudo() portal = Portal.create({ 'name': self.name, 'partner_id': self.partner_id.id, 'state': 'in_progress', 'x_fc_job_id': self.id, }) self.portal_job_id = portal.id def _fp_create_qc_check_if_needed(self): """If customer has x_fc_requires_qc=True, create a QC check. The fusion.plating.quality.check model lives in fusion_plating_bridge_mrp; we runtime-detect it to avoid a depends-on-bridge_mrp cycle. If the model isn't registered, log a warning and skip — bridge_mrp can be installed later without breaking this flow. """ self.ensure_one() partner = self.partner_id wants_qc = ( 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc ) if not wants_qc: return if 'fusion.plating.quality.check' not in self.env: _logger.warning( "Job %s: customer wants QC but fusion.plating.quality.check " "model not registered (bridge_mrp deferral).", self.name, ) return QC = self.env['fusion.plating.quality.check'].sudo() # Try to create with the most likely required fields. If the # model has a different schema than expected, this may need # adjustment when bridge_mrp's QC model lands here. try: qc_vals = { 'partner_id': partner.id, 'state': 'pending', } # Try the new field name first; fallback to mrp-bound. if 'job_id' in QC._fields: qc_vals['job_id'] = self.id elif 'production_id' in QC._fields: # bridge_mrp's QC binds to production. We can't fill that # from here — leave it null and let a manual link happen. pass QC.create(qc_vals) except Exception as e: _logger.warning( "Job %s: failed to create QC check: %s", self.name, e, ) # ------------------------------------------------------------------ # button_mark_done — Task 2.8 # ------------------------------------------------------------------ def button_mark_done(self): """Transition the job to 'done' and trigger downstream side effects. - Sets state='done', date_finished=now - Auto-creates a draft fusion.plating.delivery - Triggers certificate auto-generation (best-effort) """ # During migration, side-effects are skipped — see action_confirm. skip_side_effects = self.env.context.get('fp_jobs_migration') for job in self: if job.state == 'done': continue if job.state == 'cancelled': raise UserError( "Job %s is cancelled — cannot mark done." % job.name ) job.state = 'done' job.date_finished = fields.Datetime.now() if not skip_side_effects: job._fp_create_delivery() job._fp_create_certificates() job._fp_fire_notification('job_complete') return True # ------------------------------------------------------------------ # Notifications dispatch (Phase 4) # # Fires fp.notification.template records whose trigger_event matches # the given event name. Best-effort: silently skips if the # fusion_plating_notifications module is not installed (model not # registered) and logs (without raising) on any send failure so the # job lifecycle is never blocked by an email problem. # ------------------------------------------------------------------ def _fp_fire_notification(self, event): """Best-effort notification dispatch for fp.job lifecycle events. Looks up fp.notification.template records with the matching trigger_event and dispatches via the central _dispatch helper provided by fusion_plating_notifications. Silently no-ops when that module isn't installed. """ self.ensure_one() if 'fp.notification.template' not in self.env: return Template = self.env['fp.notification.template'].sudo() try: # The notifications module exposes a model-level _dispatch # helper that handles template lookup, recipient resolution # (Sub 6 contact routing), attachment rendering, and audit # logging in one go. Pass partner explicitly since fp.job's # partner_id is the customer. Template._dispatch(event, self, partner=self.partner_id) except Exception as e: _logger.warning( "Job %s: notification %s dispatch failed: %s", self.name, event, e, ) def _fp_create_delivery(self): """Create a draft fusion.plating.delivery linked to this job.""" self.ensure_one() if self.delivery_id: return Delivery = self.env['fusion.plating.delivery'].sudo() # Verify the model has a job link field. The current delivery # model uses `job_ref` (Char) as a soft reference; some forks # may add `x_fc_job_id` (Many2one). if 'x_fc_job_id' in Delivery._fields: ref_field = 'x_fc_job_id' ref_value = self.id elif 'job_ref' in Delivery._fields: ref_field = 'job_ref' ref_value = self.name else: _logger.warning( "Job %s: fusion.plating.delivery has no job link field; " "delivery created without job back-reference.", self.name, ) ref_field = None ref_value = None try: vals = { 'partner_id': self.partner_id.id, } if ref_field: vals[ref_field] = ref_value delivery = Delivery.create(vals) self.delivery_id = delivery.id except Exception as e: _logger.warning( "Job %s: failed to auto-create delivery: %s", self.name, e, ) def _fp_create_certificates(self): """Trigger cert auto-create on job done. Best-effort: if fp.certificate has the right fields, create a draft CoC. Otherwise log + skip. """ self.ensure_one() if 'fp.certificate' not in self.env: return Cert = self.env['fp.certificate'].sudo() try: vals = { 'partner_id': self.partner_id.id, } if 'certificate_type' in Cert._fields: vals['certificate_type'] = 'coc' if 'state' in Cert._fields: vals['state'] = 'draft' # Add job link if Cert has the field if 'x_fc_job_id' in Cert._fields: vals['x_fc_job_id'] = self.id elif 'job_id' in Cert._fields: vals['job_id'] = self.id elif 'sale_order_id' in Cert._fields and self.sale_order_id: vals['sale_order_id'] = self.sale_order_id.id Cert.create(vals) except Exception as e: _logger.warning( "Job %s: failed to auto-create cert: %s", self.name, e, ) class FpJobStep(models.Model): """Phase 7 — adds the migration idempotency key on fp.job.step. Populated by scripts/migrate_to_fp_jobs.py to mark a step as the mirror of a specific mrp.workorder. Used to skip already-migrated WOs on subsequent runs. """ _inherit = 'fp.job.step' legacy_mrp_workorder_id = fields.Integer( string='Legacy MRP Work Order ID', index=True, help='Database id of the source mrp.workorder this step was ' 'migrated from. Used by the migration script for ' 'idempotency. Cleared post-cutover.', )