folder rename
This commit is contained in:
7
fusion_plating/fusion_plating_bridge_mrp/__init__.py
Normal file
7
fusion_plating/fusion_plating_bridge_mrp/__init__.py
Normal 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
|
||||
61
fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
Normal file
61
fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
Normal 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,
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
15
fusion_plating/fusion_plating_bridge_mrp/models/__init__.py
Normal file
15
fusion_plating/fusion_plating_bridge_mrp/models/__init__.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
32
fusion_plating/fusion_plating_bridge_mrp/models/fp_batch.py
Normal file
32
fusion_plating/fusion_plating_bridge_mrp/models/fp_batch.py
Normal 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)
|
||||
@@ -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
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
@@ -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.',
|
||||
)
|
||||
399
fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
Normal file
399
fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
Normal 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}
|
||||
@@ -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
|
||||
|
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & Cost" and add cost summary -->
|
||||
<xpath expr="//notebook/page[@name='time_tracking']" position="attributes">
|
||||
<attribute name="string">Time & 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 & 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>
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user