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,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}