folder rename

This commit is contained in:
gsinghpal
2026-04-16 20:53:53 -04:00
parent 3f3ddcbab4
commit 7c7ef06057
634 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import models
from . import wizard

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating — MRP Bridge',
'version': '19.0.2.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
Fusion Plating — MRP Bridge
============================
Part of the Fusion Plating product family by Nexa Systems Inc.
Links Fusion Plating infrastructure (facilities, work centres, baths, tanks)
to Odoo's native MRP manufacturing orders and work orders so shops can:
* Assign a plating facility and FP work centre to an MRP work centre.
* Tag each work order with the specific bath, tank, rack/fixture, target
thickness, and dwell time for traceability.
* Attach a customer specification and facility to a manufacturing order.
* Create an MRP work centre directly from a Fusion Plating work centre.
* Link a portal job to a manufacturing order for customer visibility.
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
'fusion_plating_portal',
'fusion_plating_quality',
'fusion_plating_logistics',
'fusion_plating_batch',
'mrp',
'mrp_workorder',
'mrp_account',
'sale_mrp',
'account',
],
'data': [
'security/ir.model.access.csv',
'wizard/fp_recipe_config_wizard_views.xml',
'views/mrp_workcenter_views.xml',
'views/mrp_workorder_views.xml',
'views/mrp_production_views.xml',
'views/fp_quality_hold_views.xml',
'views/fp_batch_views.xml',
'views/fp_workorder_priority_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import mrp_workcenter
from . import mrp_workorder
from . import mrp_production
from . import fp_work_center
from . import fp_portal_job
from . import fp_quality_hold
from . import fp_delivery
from . import fp_batch
from . import fp_job_node_override
from . import account_move

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import models
class AccountMove(models.Model):
"""Extend invoice to auto-complete portal job when posted.
GAP 7: Invoice posted → find portal job for same customer/SO
→ set state "complete" + invoice_ref.
"""
_inherit = 'account.move'
def action_post(self):
"""Override to cascade invoice posting to portal job completion."""
res = super().action_post()
PortalJob = self.env.get('fusion.plating.portal.job')
if PortalJob is None:
return res
for invoice in self:
if invoice.move_type != 'out_invoice':
continue
# Find portal jobs for this customer that are shipped but not complete
# Match by SO origin from the invoice lines
origin = invoice.invoice_origin or ''
jobs = PortalJob.browse()
if origin:
# Try to find MO linked to this SO, then its portal job
mos = self.env['mrp.production'].search(
[('origin', '=', origin)],
)
for mo in mos:
if mo.x_fc_portal_job_id and mo.x_fc_portal_job_id.state == 'shipped':
jobs |= mo.x_fc_portal_job_id
# Fallback: find shipped jobs for same partner
if not jobs:
jobs = PortalJob.search([
('partner_id', '=', invoice.partner_id.id),
('state', '=', 'shipped'),
('invoice_ref', '=', False),
], limit=1)
for job in jobs:
job.write({
'state': 'complete',
'invoice_ref': invoice.name,
})
job.message_post(
body='Invoice %s posted — job complete.' % invoice.name,
)
return res

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpBatch(models.Model):
"""Extend batch with M2M link to MRP work orders.
GAP 6: Batch ↔ Work Order linkage so the shop knows which WOs
were processed in which tank batch (rack/barrel load).
"""
_inherit = 'fusion.plating.batch'
workorder_ids = fields.Many2many(
'mrp.workorder',
'fp_batch_mrp_workorder_rel',
'batch_id',
'workorder_id',
string='Work Orders',
help='MRP work orders processed in this batch.',
)
workorder_count = fields.Integer(
string='WO Count',
compute='_compute_workorder_count',
)
def _compute_workorder_count(self):
for rec in self:
rec.workorder_count = len(rec.workorder_ids)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpDelivery(models.Model):
"""Extend delivery to auto-update portal job when delivered.
GAP 5: Delivery marked "delivered" → portal job → "shipped"
+ set actual_ship_date on the job.
"""
_inherit = 'fusion.plating.delivery'
def action_mark_delivered(self):
"""Override to cascade delivery completion to the portal job."""
res = super().action_mark_delivered()
PortalJob = self.env['fusion.plating.portal.job']
for delivery in self:
if not delivery.job_ref:
continue
# Find the portal job by name/reference
job = PortalJob.search(
[('name', '=', delivery.job_ref)], limit=1,
)
if not job:
continue
job.write({
'state': 'shipped',
'actual_ship_date': fields.Date.today(),
'tracking_ref': delivery.name,
})
job.message_post(body='Parts shipped — delivery %s marked delivered.' % delivery.name)
return res

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpJobNodeOverride(models.Model):
"""Per-job override for optional recipe steps.
When a recipe is assigned to a manufacturing order, nodes with
opt_in_out != 'disabled' can be toggled on or off for that specific
job. Opt-in nodes default to excluded; opt-out nodes default to
included. The planner changes these via the configuration wizard.
"""
_name = 'fusion.plating.job.node.override'
_description = 'Fusion Plating — Job Node Override'
_order = 'node_sequence, id'
production_id = fields.Many2one(
'mrp.production',
string='Manufacturing Order',
required=True,
ondelete='cascade',
index=True,
)
node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Step',
required=True,
ondelete='cascade',
)
node_name = fields.Char(
related='node_id.name',
string='Step Name',
readonly=True,
)
node_type = fields.Selection(
related='node_id.node_type',
string='Type',
readonly=True,
)
node_sequence = fields.Integer(
related='node_id.sequence',
string='Sequence',
readonly=True,
store=True,
)
opt_in_out = fields.Selection(
related='node_id.opt_in_out',
string='Default',
readonly=True,
)
included = fields.Boolean(
string='Included',
default=True,
help='Whether this optional step is active for this job.',
)
_sql_constraints = [
('unique_production_node',
'unique(production_id, node_id)',
'Each recipe step can only have one override per job.'),
]

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpPortalJob(models.Model):
"""Extend portal job with a link to its manufacturing order."""
_inherit = 'fusion.plating.portal.job'
x_fc_production_id = fields.Many2one(
'mrp.production',
string='Manufacturing Order',
help='The Odoo manufacturing order linked to this portal job.',
)

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpQualityHoldMrp(models.Model):
"""Add MRP references to the quality hold record.
These fields live here (not in fusion_plating_quality) so the QMS
module can install without an mrp dependency.
"""
_inherit = 'fusion.plating.quality.hold'
workorder_id = fields.Many2one(
'mrp.workorder',
string='Work Order',
)
production_id = fields.Many2one(
'mrp.production',
string='Manufacturing Order',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
)

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import fields, models
_logger = logging.getLogger(__name__)
class FpWorkCenter(models.Model):
"""Extend FP work centre with a link to its mirrored MRP work centre."""
_inherit = 'fusion.plating.work.center'
x_fc_mrp_workcenter_id = fields.Many2one(
'mrp.workcenter',
string='MRP Work Centre',
copy=False,
help='The Odoo MRP work centre that mirrors this FP work centre.',
)
def action_sync_to_mrp(self):
"""Create or update the mirrored mrp.workcenter record.
If the FP work centre already has an MRP work centre linked,
update its name and facility reference. Otherwise create a new
one and link it back.
"""
Workcenter = self.env['mrp.workcenter']
for rec in self:
vals = {
'name': rec.name,
'code': rec.code,
'x_fc_facility_id': rec.facility_id.id,
'x_fc_fp_work_center_id': rec.id,
'company_id': rec.facility_id.company_id.id,
}
if rec.x_fc_mrp_workcenter_id:
rec.x_fc_mrp_workcenter_id.write(vals)
_logger.info(
'Fusion Plating MRP bridge: updated mrp.workcenter %s '
'from FP work centre %s',
rec.x_fc_mrp_workcenter_id.id, rec.name,
)
else:
wc = Workcenter.create(vals)
rec.x_fc_mrp_workcenter_id = wc.id
_logger.info(
'Fusion Plating MRP bridge: created mrp.workcenter %s '
'from FP work centre %s',
wc.id, rec.name,
)
return {'type': 'ir.actions.client', 'tag': 'reload'}

View File

@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# 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
workflow automations that bridge MO lifecycle → portal job → delivery.
"""
_inherit = 'mrp.production'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
help='The customer specification governing this manufacturing order.',
)
x_fc_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
help='The Fusion Plating facility where this order is produced.',
)
x_fc_portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
help='The portal job linked to this manufacturing order.',
)
x_fc_recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
domain=[('node_type', '=', 'recipe')],
help='Process recipe template for this manufacturing order.',
tracking=True,
)
x_fc_override_ids = fields.One2many(
'fusion.plating.job.node.override',
'production_id',
string='Recipe Overrides',
)
x_fc_override_count = fields.Integer(
string='Overrides',
compute='_compute_override_count',
)
@api.depends('x_fc_override_ids')
def _compute_override_count(self):
for rec in self:
rec.x_fc_override_count = len(rec.x_fc_override_ids)
def action_configure_recipe_steps(self):
"""Open the wizard to configure opt-in/out steps for this job."""
self.ensure_one()
if not self.x_fc_recipe_id:
raise UserError(_('Please select a recipe first.'))
return {
'type': 'ir.actions.act_window',
'name': f'Configure Steps — {self.x_fc_recipe_id.name}',
'res_model': 'fp.recipe.config.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_production_id': self.id,
'default_recipe_id': self.x_fc_recipe_id.id,
},
}
# ------------------------------------------------------------------
# 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 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:
if mo.x_fc_portal_job_id:
# Already linked — just update state
mo.x_fc_portal_job_id.write({'state': 'in_progress'})
continue
# Resolve customer from sale order via origin
partner = False
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so:
partner = so.partner_id
if not partner:
continue # No customer — skip portal job creation
job = PortalJob.create({
'name': mo.name,
'partner_id': partner.id,
'state': 'in_progress',
'received_date': fields.Date.today(),
'target_ship_date': (
mo.date_start.date() + __import__('datetime').timedelta(days=10)
if mo.date_start else False
),
'quantity': int(mo.product_qty),
'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
# ------------------------------------------------------------------
# GAP 3+4: MO done → update portal job + auto-create delivery
# ------------------------------------------------------------------
def button_mark_done(self):
"""Override to cascade MO completion to portal job and delivery."""
res = super().button_mark_done()
Delivery = self.env.get('fusion.plating.delivery')
for mo in self:
job = mo.x_fc_portal_job_id
if not job:
continue
# GAP 3: MO done → portal job ready_to_ship
job.write({'state': 'ready_to_ship'})
job.message_post(body='Manufacturing complete — ready to ship.')
# GAP 4: Auto-create delivery record
if Delivery is None:
continue
partner = job.partner_id
Delivery.create({
'partner_id': partner.id,
'job_ref': job.name,
'source_facility_id': mo.x_fc_facility_id.id if mo.x_fc_facility_id else False,
'state': 'draft',
})
return res

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class MrpWorkcenter(models.Model):
"""Extend MRP work centre with Fusion Plating facility and work centre."""
_inherit = 'mrp.workcenter'
x_fc_facility_id = fields.Many2one(
'fusion.plating.facility',
string='Plating Facility',
help='The Fusion Plating facility this MRP work centre belongs to.',
)
x_fc_fp_work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='FP Work Centre',
domain="[('facility_id', '=', x_fc_facility_id)]",
help='The Fusion Plating work centre mirrored by this MRP work centre.',
)

View File

@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class MrpWorkorder(models.Model):
"""Extend work order with plating fields, priority, chatter,
workflow step tracking, and smart-button computed fields.
"""
_name = 'mrp.workorder'
_inherit = ['mrp.workorder', 'mail.thread', 'mail.activity.mixin']
# ------------------------------------------------------------------
# Priority (Normal / Urgent / Hot)
# ------------------------------------------------------------------
x_fc_priority = fields.Selection(
[('0', 'Normal'), ('1', 'Urgent'), ('2', 'Hot')],
string='Priority',
default='0',
tracking=True,
)
# ------------------------------------------------------------------
# Plating-specific fields
# ------------------------------------------------------------------
x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True,
)
x_fc_tank_id = fields.Many2one(
'fusion.plating.tank', string='Tank',
)
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref')
x_fc_thickness_target = fields.Float(string='Target Thickness')
x_fc_thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', '\u00b5m')],
string='Thickness Unit', default='mils',
)
x_fc_dwell_time_minutes = fields.Float(string='Dwell Time (min)')
x_fc_facility_id = fields.Many2one(
'fusion.plating.facility', string='Facility',
related='workcenter_id.x_fc_facility_id', store=True, readonly=True,
)
x_fc_workcenter_cost_hour = fields.Float(
string='Station Rate ($/hr)',
related='workcenter_id.costs_hour', readonly=True,
)
# ------------------------------------------------------------------
# Workflow step tracking
# ------------------------------------------------------------------
x_fc_step_number = fields.Integer(
string='Step #', compute='_compute_step_info', store=True,
)
x_fc_total_steps = fields.Integer(
string='Total Steps', compute='_compute_step_info', store=True,
)
x_fc_step_display = fields.Char(
string='Current Step', compute='_compute_step_info', store=True,
)
@api.depends('production_id.workorder_ids', 'sequence')
def _compute_step_info(self):
for wo in self:
siblings = wo.production_id.workorder_ids.sorted('sequence')
total = len(siblings)
step = 0
for i, s in enumerate(siblings, 1):
if s.id == wo.id:
step = i
break
wo.x_fc_step_number = step
wo.x_fc_total_steps = total
wo.x_fc_step_display = f"Step {step} of {total}" if total else ""
# ------------------------------------------------------------------
# Smart button computes
# ------------------------------------------------------------------
x_fc_sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
compute='_compute_sale_order',
)
x_fc_portal_job_id = fields.Many2one(
'fusion.plating.portal.job', string='Portal Job',
compute='_compute_portal_job',
)
x_fc_customer_id = fields.Many2one(
'res.partner', string='Customer',
compute='_compute_customer', store=True,
)
x_fc_sale_order_name = fields.Char(
string='SO #', compute='_compute_sale_order', store=False,
)
x_fc_production_name = fields.Char(
string='MO #', related='production_id.name', store=False,
)
x_fc_quality_hold_count = fields.Integer(
string='Quality Holds', compute='_compute_quality_hold_count',
)
x_fc_delivery_count = fields.Integer(
string='Deliveries', compute='_compute_delivery_count',
)
@api.depends('production_id.origin')
def _compute_customer(self):
SO = self.env['sale.order']
for wo in self:
origin = wo.production_id.origin or ''
if origin:
so = SO.search([('name', '=', origin)], limit=1)
wo.x_fc_customer_id = so.partner_id if so else False
else:
wo.x_fc_customer_id = False
def _compute_sale_order(self):
SO = self.env['sale.order']
for wo in self:
origin = wo.production_id.origin or ''
if origin:
so = SO.search([('name', '=', origin)], limit=1)
wo.x_fc_sale_order_id = so
wo.x_fc_sale_order_name = so.name if so else ''
else:
wo.x_fc_sale_order_id = False
wo.x_fc_sale_order_name = ''
def _compute_portal_job(self):
for wo in self:
wo.x_fc_portal_job_id = (
wo.production_id.x_fc_portal_job_id
if wo.production_id else False
)
def _compute_quality_hold_count(self):
Hold = self.env.get('fusion.plating.quality.hold')
for wo in self:
if Hold and 'workorder_id' in Hold._fields:
wo.x_fc_quality_hold_count = Hold.search_count(
[('workorder_id', '=', wo.id)]
)
else:
wo.x_fc_quality_hold_count = 0
def _compute_delivery_count(self):
Delivery = self.env.get('fusion.plating.delivery')
for wo in self:
if Delivery and wo.production_id.x_fc_portal_job_id:
wo.x_fc_delivery_count = Delivery.search_count(
[('job_ref', '=', wo.production_id.x_fc_portal_job_id.name)]
)
else:
wo.x_fc_delivery_count = 0
# ------------------------------------------------------------------
# Smart button actions
# ------------------------------------------------------------------
def action_view_sale_order(self):
self.ensure_one()
so = self.x_fc_sale_order_id
if not so:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_manufacturing_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'mrp.production',
'res_id': self.production_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_portal_job(self):
self.ensure_one()
job = self.x_fc_portal_job_id
if not job:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.portal.job',
'res_id': job.id,
'view_mode': 'form',
'target': 'current',
}
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': [('workorder_id', '=', self.id)],
'target': 'current',
}
def action_view_deliveries(self):
self.ensure_one()
job = self.x_fc_portal_job_id
if not job:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.delivery',
'view_mode': 'list,form',
'domain': [('job_ref', '=', job.name)],
'target': 'current',
}
# ------------------------------------------------------------------
# Process tree action (opens OWL client action)
# ------------------------------------------------------------------
def action_view_process_tree(self):
"""Open the OWL process tree view for this MO's routing."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_process_tree',
'name': f'Process Tree — {self.production_id.name}',
'context': {'production_id': self.production_id.id},
}
# ------------------------------------------------------------------
# Process flow for horizontal pipeline bar
# ------------------------------------------------------------------
def get_process_flow(self):
"""Return process flow steps for the horizontal pipeline bar.
Returns a list of dicts, one per WO in this MO's routing:
[
{
'wo_id': 42,
'name': 'Incoming Inspection',
'workcenter': 'Incoming Inspection',
'sequence': 10,
'state': 'done',
'is_current': False,
'duration': 12.5,
'duration_expected': 15.0,
'duration_display': '12m',
},
...
]
"""
self.ensure_one()
siblings = self.production_id.workorder_ids.sorted('sequence')
result = []
for wo in siblings:
# Human-readable duration
dur = wo.duration or 0
if dur >= 60:
dur_display = f"{int(dur // 60)}h {int(dur % 60)}m"
elif dur > 0:
dur_display = f"{int(dur)}m"
else:
dur_display = ''
result.append({
'wo_id': wo.id,
'name': wo.name or wo.workcenter_id.name or '',
'workcenter': wo.workcenter_id.name or '',
'sequence': wo.sequence,
'state': wo.state,
'is_current': wo.id == self.id,
'duration': round(dur, 1),
'duration_expected': round(wo.duration_expected or 0, 1),
'duration_display': dur_display,
})
return result
# ------------------------------------------------------------------
# Cost summary for Time & Cost tab
# ------------------------------------------------------------------
def get_cost_summary(self):
"""Return cost breakdown for all WOs in this MO.
Returns:
{
'revenue': 450.00,
'station_costs': [
{'station': 'Alkaline Clean', 'rate': 30.0, 'duration': 34.5,
'labour_cost': 17.25, 'operation_cost': 5.75, 'total': 23.00},
...
],
'total_labour': 204.58,
'total_operation': 84.59,
'total_material': 76.50,
'total_cost': 365.67,
'gross_profit': 84.33,
'margin_pct': 19.0,
}
"""
self.ensure_one()
mo = self.production_id
# Revenue from sale order
revenue = 0
if mo.origin:
so = self.env['sale.order'].search([('name', '=', mo.origin)], limit=1)
if so:
revenue = sum(so.order_line.mapped('price_subtotal'))
# Station costs from all WOs
station_costs = []
total_labour = 0
total_operation = 0
for wo in mo.workorder_ids.sorted('sequence'):
rate = wo.costs_hour or wo.workcenter_id.costs_hour or 0
dur_hours = (wo.duration or 0) / 60.0
labour = dur_hours * rate
# Operation cost (dwell time based)
op_rate = rate * 0.5 # simplified: operation = 50% of labour rate
dwell = getattr(wo, 'x_fc_dwell_time_minutes', 0) or 0
op_cost = (dwell / 60.0) * op_rate
total = labour + op_cost
# Duration display
wo_dur = wo.duration or 0
if wo_dur >= 60:
wo_dur_display = f"{int(wo_dur // 60)}h {int(wo_dur % 60)}m"
else:
wo_dur_display = f"{int(wo_dur)}m"
station_costs.append({
'wo_id': wo.id,
'station': wo.workcenter_id.name or wo.name,
'rate': rate,
'duration_minutes': round(wo_dur, 1),
'duration_display': wo_dur_display,
'labour_cost': round(labour, 2),
'operation_cost': round(op_cost, 2),
'total': round(total, 2),
'state': wo.state,
})
total_labour += labour
total_operation += op_cost
# Material cost
total_material = sum(
m.product_id.standard_price * m.quantity
for m in mo.move_raw_ids
if m.state == 'done'
) if hasattr(mo, 'move_raw_ids') else 0
total_cost = total_labour + total_operation + total_material
gross_profit = revenue - total_cost
margin_pct = (gross_profit / revenue * 100) if revenue else 0
return {
'revenue': round(revenue, 2),
'station_costs': station_costs,
'total_labour': round(total_labour, 2),
'total_operation': round(total_operation, 2),
'total_material': round(total_material, 2),
'total_cost': round(total_cost, 2),
'gross_profit': round(gross_profit, 2),
'margin_pct': round(margin_pct, 1),
}
# ------------------------------------------------------------------
# Quality data (holds + NCRs)
# ------------------------------------------------------------------
def get_quality_data(self):
"""Return quality holds and linked NCRs for this WO."""
self.ensure_one()
holds = []
ncrs = []
Hold = self.env.get('fusion.plating.quality.hold')
if Hold and 'workorder_id' in Hold._fields:
for h in Hold.search([('workorder_id', '=', self.id)]):
holds.append({
'id': h.id,
'name': h.name,
'state': h.state,
'qty': h.qty_on_hold,
'reason': h.hold_reason,
'part_ref': h.part_ref or '',
})
NCR = self.env.get('fusion.plating.ncr')
if NCR:
bath_ids = self.production_id.workorder_ids.mapped('x_fc_bath_id').ids
if bath_ids:
for n in NCR.search([('bath_id', 'in', bath_ids)]):
ncrs.append({
'id': n.id,
'name': n.name,
'state': n.state,
'severity': n.severity,
'part_ref': n.part_ref or '',
})
return {'holds': holds, 'ncrs': ncrs}

View File

@@ -0,0 +1,14 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_bridge_mrp_workcenter_manager,fp.bridge.mrp.workcenter.manager,mrp.model_mrp_workcenter,fusion_plating.group_fusion_plating_manager,1,1,1,0
access_fp_bridge_mrp_workcenter_supervisor,fp.bridge.mrp.workcenter.supervisor,mrp.model_mrp_workcenter,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_manager,1,1,1,0
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_bridge_mrp_workcenter_manager fp.bridge.mrp.workcenter.manager mrp.model_mrp_workcenter fusion_plating.group_fusion_plating_manager 1 1 1 0
3 access_fp_bridge_mrp_workcenter_supervisor fp.bridge.mrp.workcenter.supervisor mrp.model_mrp_workcenter fusion_plating.group_fusion_plating_supervisor 1 0 0 0
4 access_fp_bridge_mrp_workorder_manager fp.bridge.mrp.workorder.manager mrp_workorder.model_mrp_workorder fusion_plating.group_fusion_plating_manager 1 1 1 0
5 access_fp_bridge_mrp_workorder_supervisor fp.bridge.mrp.workorder.supervisor mrp_workorder.model_mrp_workorder fusion_plating.group_fusion_plating_supervisor 1 0 0 0
6 access_fp_bridge_mrp_production_manager fp.bridge.mrp.production.manager mrp.model_mrp_production fusion_plating.group_fusion_plating_manager 1 1 1 0
7 access_fp_bridge_mrp_production_supervisor fp.bridge.mrp.production.supervisor mrp.model_mrp_production fusion_plating.group_fusion_plating_supervisor 1 0 0 0
8 access_fp_job_node_override_operator fp.job.node.override.operator model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0
9 access_fp_job_node_override_supervisor fp.job.node.override.supervisor model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
10 access_fp_job_node_override_manager fp.job.node.override.manager model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_recipe_config_wizard_supervisor fp.recipe.config.wizard.supervisor model_fp_recipe_config_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 0
12 access_fp_recipe_config_wizard_manager fp.recipe.config.wizard.manager model_fp_recipe_config_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
13 access_fp_recipe_config_wizard_line_supervisor fp.recipe.config.wizard.line.supervisor model_fp_recipe_config_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
14 access_fp_recipe_config_wizard_line_manager fp.recipe.config.wizard.line.manager model_fp_recipe_config_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Extend batch form with work order M2M when MRP bridge is installed.
-->
<odoo>
<record id="view_fp_batch_form_mrp_bridge" model="ir.ui.view">
<field name="name">fusion.plating.batch.form.mrp.bridge</field>
<field name="model">fusion.plating.batch</field>
<field name="inherit_id" ref="fusion_plating_batch.view_fp_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='chemistry_ids']/.." position="after">
<page string="Work Orders" name="work_orders">
<field name="workorder_ids" widget="many2many_tags"/>
<p class="text-muted" invisible="workorder_ids">
Link MRP work orders processed in this batch for full traceability.
</p>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Extend the quality hold form with MRP fields when the bridge is installed.
-->
<odoo>
<record id="view_fp_quality_hold_form_mrp_bridge" model="ir.ui.view">
<field name="name">fusion.plating.quality.hold.form.mrp.bridge</field>
<field name="model">fusion.plating.quality.hold</field>
<field name="inherit_id" ref="fusion_plating_quality.view_fp_quality_hold_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='resolution']" position="before">
<group string="Source"
invisible="not workorder_id and not production_id and not portal_job_id">
<field name="workorder_id" readonly="1"/>
<field name="production_id" readonly="1"/>
<field name="portal_job_id" readonly="1"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Manager priority dashboard for MRP work orders. Drag-drop kanban
for reordering production priorities. Only visible when MRP bridge
is installed.
-->
<odoo>
<!-- Kanban view for work order prioritisation (MRP bridge required) -->
<record id="view_mrp_workorder_fp_kanban" model="ir.ui.view">
<field name="name">mrp.workorder.fp.priority.kanban</field>
<field name="model">mrp.workorder</field>
<field name="arch" type="xml">
<kanban default_group_by="workcenter_id" default_order="sequence, date_start">
<field name="name"/>
<field name="workcenter_id"/>
<field name="production_id"/>
<field name="state"/>
<field name="sequence"/>
<field name="duration"/>
<field name="qty_remaining"/>
<field name="date_start"/>
<templates>
<t t-name="card">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_record_top mb-0">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="production_id"/>
</strong>
</div>
</div>
<div class="o_kanban_record_body">
<div><strong><field name="name"/></strong></div>
<div class="text-muted">
Qty: <field name="qty_remaining"/>
<t t-if="record.duration.raw_value"><field name="duration" widget="float_time"/> elapsed</t>
</div>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<field name="state" widget="badge"
decoration-info="state == 'ready'"
decoration-warning="state == 'progress'"
decoration-success="state == 'done'"/>
</div>
<div class="oe_kanban_bottom_right">
<field name="date_start" widget="date"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- List view for priority management -->
<record id="view_mrp_workorder_fp_list" model="ir.ui.view">
<field name="name">mrp.workorder.fp.priority.list</field>
<field name="model">mrp.workorder</field>
<field name="arch" type="xml">
<list default_order="sequence, date_start">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="production_id"/>
<field name="workcenter_id"/>
<field name="qty_remaining"/>
<field name="duration" widget="float_time"/>
<field name="state" widget="badge"
decoration-info="state == 'ready'"
decoration-warning="state == 'progress'"
decoration-success="state == 'done'"/>
<field name="date_start"/>
</list>
</field>
</record>
<!-- Action: Production Priorities (Shopfloor Manager) -->
<record id="action_fp_workorder_priority" model="ir.actions.act_window">
<field name="name">Production Priorities</field>
<field name="res_model">mrp.workorder</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('state', 'in', ['ready', 'progress', 'pending'])]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_mrp_workorder_fp_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_mrp_workorder_fp_list')})]"/>
</record>
<!-- Menu: Production Priorities under Operations -->
<menuitem id="menu_fp_workorder_priority"
name="Production Priorities"
parent="fusion_plating.menu_fp_operations"
action="action_fp_workorder_priority"
sequence="10"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- Extend mrp.production form: add Fusion Plating fields -->
<record id="view_mrp_production_form_fp_bridge" model="ir.ui.view">
<field name="name">mrp.production.form.fp.bridge</field>
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Fusion Plating" name="fusion_plating">
<group>
<field name="x_fc_customer_spec_id"/>
<field name="x_fc_facility_id"/>
</group>
<group>
<field name="x_fc_portal_job_id"/>
<field name="x_fc_recipe_id"/>
</group>
</group>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_configure_recipe_steps" type="object"
class="oe_stat_button" icon="fa-sliders"
invisible="not x_fc_recipe_id">
<field name="x_fc_override_count" widget="statinfo"
string="Overrides"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<!-- Extend mrp.workcenter form: add Fusion Plating group -->
<record id="view_mrp_workcenter_form_fp_bridge" model="ir.ui.view">
<field name="name">mrp.workcenter.form.fp.bridge</field>
<field name="model">mrp.workcenter</field>
<field name="inherit_id" ref="mrp.mrp_workcenter_view"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Fusion Plating" name="fusion_plating">
<group>
<field name="x_fc_facility_id"/>
<field name="x_fc_fp_work_center_id"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Hub & Spoke layout for Work Order form:
- Smart buttons (SO, MO, Portal Job, Quality Holds, Deliveries)
- Process flow bar with step badge + tree action
- 5-tab notebook: Time & Cost | Plating Details | Quality | Components | Blocked By
- Chatter
-->
<odoo>
<record id="view_mrp_workorder_form_fp_bridge" model="ir.ui.view">
<field name="name">mrp.workorder.form.fp.bridge</field>
<field name="model">mrp.workorder</field>
<field name="inherit_id" ref="mrp.mrp_production_workorder_form_view_inherit"/>
<field name="arch" type="xml">
<!-- ============================================================
1. HIDDEN COMPUTED FIELDS (inside sheet, after company_id)
============================================================ -->
<xpath expr="//sheet//field[@name='company_id']" position="after">
<field name="x_fc_sale_order_id" invisible="1"/>
<field name="x_fc_portal_job_id" invisible="1"/>
<field name="x_fc_sale_order_name" invisible="1"/>
<field name="x_fc_production_name" invisible="1"/>
<field name="x_fc_step_number" invisible="1"/>
<field name="x_fc_total_steps" invisible="1"/>
</xpath>
<!-- ============================================================
2. SMART BUTTONS (inside button_box)
============================================================ -->
<xpath expr="//div[@name='button_box']" position="inside">
<!-- Sale Order -->
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not x_fc_sale_order_id">
<div class="o_stat_info">
<span class="o_stat_value">
<field name="x_fc_sale_order_name" nolabel="1"/>
</span>
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<!-- Manufacturing Order -->
<button name="action_view_manufacturing_order" type="object"
class="oe_stat_button" icon="fa-industry">
<div class="o_stat_info">
<span class="o_stat_value">
<field name="x_fc_production_name" nolabel="1"/>
</span>
<span class="o_stat_text">Manufacturing</span>
</div>
</button>
<!-- Portal Job -->
<button name="action_view_portal_job" type="object"
class="oe_stat_button" icon="fa-globe"
invisible="not x_fc_portal_job_id">
<div class="o_stat_info">
<span class="o_stat_text">Portal Job</span>
</div>
</button>
<!-- Quality Holds -->
<button name="action_view_quality_holds" type="object"
class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="x_fc_quality_hold_count" widget="statinfo"
string="Quality Holds"/>
</button>
<!-- Deliveries -->
<button name="action_view_deliveries" type="object"
class="oe_stat_button" icon="fa-truck">
<field name="x_fc_delivery_count" widget="statinfo"
string="Deliveries"/>
</button>
</xpath>
<!-- ============================================================
3. CUSTOMER FIELD — left column, after workcenter_id
============================================================ -->
<xpath expr="//sheet//field[@name='workcenter_id']" position="after">
<field name="x_fc_customer_id" readonly="1"
options="{'no_open': False}"/>
</xpath>
<!-- ============================================================
3b. STEP BADGE + PRIORITY — right column, after production_id
============================================================ -->
<xpath expr="//sheet//field[@name='production_id']" position="after">
<field name="x_fc_step_display" widget="badge" readonly="1"/>
<field name="x_fc_priority" widget="priority"/>
</xpath>
<!-- ============================================================
4. PROCESS FLOW BAR (before notebook)
============================================================ -->
<xpath expr="//notebook" position="before">
<div class="o_fp_process_flow mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-2 bg-light rounded">
<i class="fa fa-random text-primary"/>
<span class="fw-bold">Process Flow:</span>
<field name="x_fc_step_display" widget="badge"
readonly="1" nolabel="1"/>
<span class="text-muted">|</span>
<span class="text-muted small">
Click "View Process Tree" for full routing
</span>
<button name="action_view_process_tree" type="object"
string="View Process Tree"
class="btn btn-sm btn-outline-primary ms-auto"
icon="fa-sitemap"/>
</div>
</div>
</xpath>
<!-- ============================================================
5. NOTEBOOK — restructured tabs
============================================================ -->
<!-- 5a. Rename "Time Tracking" → "Time &amp; Cost" and add cost summary -->
<xpath expr="//notebook/page[@name='time_tracking']" position="attributes">
<attribute name="string">Time &amp; Cost</attribute>
</xpath>
<xpath expr="//notebook/page[@name='time_tracking']" position="inside">
<group string="Cost Summary" name="cost_summary">
<group>
<field name="x_fc_workcenter_cost_hour"
string="Station Rate ($/hr)" readonly="1"/>
<field name="duration" widget="float_time"
string="Actual Duration" readonly="1"/>
</group>
<group>
<field name="duration_expected" widget="float_time"
string="Expected Duration" readonly="1"/>
</group>
</group>
</xpath>
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->
<xpath expr="//notebook/page[@name='time_tracking']" position="after">
<page string="Plating Details" name="plating_details">
<group>
<group string="Bath &amp; Tank">
<field name="x_fc_facility_id"/>
<field name="x_fc_bath_id"/>
<field name="x_fc_tank_id"/>
<field name="x_fc_rack_ref"/>
</group>
<group string="Process Parameters">
<field name="x_fc_thickness_target"/>
<field name="x_fc_thickness_uom"/>
<field name="x_fc_dwell_time_minutes"/>
</group>
</group>
</page>
</xpath>
<!-- 5c. Quality tab (insert AFTER Plating Details) -->
<xpath expr="//notebook/page[@name='plating_details']" position="after">
<page string="Quality" name="quality">
<div class="mb-3">
<button name="action_view_quality_holds" type="object"
string="View Quality Holds"
class="btn btn-sm btn-outline-warning"
icon="fa-exclamation-triangle"/>
</div>
<p class="text-muted" invisible="x_fc_quality_hold_count > 0">
No quality holds on this work order.
</p>
<div invisible="x_fc_quality_hold_count == 0">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle me-1"/>
<strong>
<field name="x_fc_quality_hold_count" nolabel="1"/>
quality hold(s)
</strong>
on this work order. Click the button above to review.
</div>
</div>
</page>
</xpath>
<!-- 5d. Components tab already exists at name="components" — no change needed -->
<!-- 5e. Blocked By tab — keep existing, just push it last visually
(it's already the last page in the base view, so no reorder needed) -->
<!-- ============================================================
6. CHATTER (after sheet)
============================================================ -->
<xpath expr="//sheet" position="after">
<chatter/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_recipe_config_wizard

View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpRecipeConfigWizard(models.TransientModel):
"""Wizard to configure which optional recipe steps are included
for a specific manufacturing order.
Shows all nodes where opt_in_out != 'disabled' as a checklist.
Opt-in nodes default unchecked (skipped), opt-out nodes default
checked (included). On confirm, creates or updates override records.
"""
_name = 'fp.recipe.config.wizard'
_description = 'Configure Recipe Steps'
production_id = fields.Many2one(
'mrp.production',
string='Manufacturing Order',
required=True,
)
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
required=True,
)
line_ids = fields.One2many(
'fp.recipe.config.wizard.line',
'wizard_id',
string='Optional Steps',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
production_id = res.get('production_id') or self.env.context.get('default_production_id')
recipe_id = res.get('recipe_id') or self.env.context.get('default_recipe_id')
if not production_id or not recipe_id:
return res
production = self.env['mrp.production'].browse(production_id)
recipe = self.env['fusion.plating.process.node'].browse(recipe_id)
# Collect all optional nodes (recursive)
optional_nodes = self._get_optional_nodes(recipe)
if not optional_nodes:
return res
# Check for existing overrides
existing = {
ov.node_id.id: ov.included
for ov in production.x_fc_override_ids
}
lines = []
for node in optional_nodes:
if node.id in existing:
included = existing[node.id]
else:
# Default: opt-in → False (skipped), opt-out → True (included)
included = node.opt_in_out == 'opt_out'
lines.append((0, 0, {
'node_id': node.id,
'included': included,
}))
res['line_ids'] = lines
return res
def _get_optional_nodes(self, node):
"""Recursively collect all nodes with opt_in_out != 'disabled'."""
result = []
if node.opt_in_out and node.opt_in_out != 'disabled':
result.append(node)
for child in node.child_ids.sorted('sequence'):
result.extend(self._get_optional_nodes(child))
return result
def action_confirm(self):
"""Save overrides and close wizard."""
self.ensure_one()
Override = self.env['fusion.plating.job.node.override']
production = self.production_id
# Delete existing overrides for this MO and recreate
production.x_fc_override_ids.unlink()
for line in self.line_ids:
Override.create({
'production_id': production.id,
'node_id': line.node_id.id,
'included': line.included,
})
return {'type': 'ir.actions.act_window_close'}
class FpRecipeConfigWizardLine(models.TransientModel):
"""One line in the recipe config wizard — an optional step."""
_name = 'fp.recipe.config.wizard.line'
_description = 'Recipe Config Wizard Line'
_order = 'node_sequence, id'
wizard_id = fields.Many2one(
'fp.recipe.config.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
node_id = fields.Many2one(
'fusion.plating.process.node',
string='Step',
required=True,
)
node_name = fields.Char(
related='node_id.name',
string='Step Name',
readonly=True,
)
node_type = fields.Selection(
related='node_id.node_type',
string='Type',
readonly=True,
)
node_sequence = fields.Integer(
related='node_id.sequence',
string='Seq',
readonly=True,
store=True,
)
opt_in_out = fields.Selection(
related='node_id.opt_in_out',
string='Default',
readonly=True,
)
included = fields.Boolean(
string='Include in Job',
default=True,
)

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<record id="view_fp_recipe_config_wizard_form" model="ir.ui.view">
<field name="name">fp.recipe.config.wizard.form</field>
<field name="model">fp.recipe.config.wizard</field>
<field name="arch" type="xml">
<form string="Configure Recipe Steps">
<group>
<field name="production_id" readonly="True"/>
<field name="recipe_id" readonly="True"/>
</group>
<separator string="Optional Steps"/>
<p class="text-muted">
Toggle which optional steps are included for this job.
<strong>Opt-In</strong> steps are skipped by default — check to include.
<strong>Opt-Out</strong> steps are included by default — uncheck to skip.
</p>
<field name="line_ids">
<list editable="bottom" no_open="True">
<field name="node_name" string="Step"/>
<field name="node_type" widget="badge"
decoration-success="node_type == 'operation'"
decoration-warning="node_type == 'sub_process'"
decoration-muted="node_type == 'step'"/>
<field name="opt_in_out" widget="badge"
decoration-info="opt_in_out == 'opt_in'"
decoration-warning="opt_in_out == 'opt_out'"/>
<field name="included" widget="boolean_toggle"/>
<field name="node_id" column_invisible="True"/>
<field name="node_sequence" column_invisible="True"/>
</list>
</field>
<footer>
<button name="action_confirm" type="object"
string="Confirm" class="btn-primary"/>
<button string="Cancel" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>