This commit is contained in:
gsinghpal
2026-04-26 15:05:17 -04:00
parent 160198edb1
commit d9f58b9851
110 changed files with 6210 additions and 1182 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.5.1.0',
'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -54,6 +54,9 @@ full design rationale and §6.2 of the implementation plan for task list.
'security/ir.model.access.csv',
'views/res_config_settings_views.xml',
'views/fp_job_form_inherit.xml',
'views/sale_order_views.xml',
'views/fp_job_consumption_views.xml',
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
'report/report_fp_job_sticker.xml',

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
#
# Phase 1 (Sub 11) — relocate ir.model.data XML IDs from
# fusion_plating_bridge_mrp to fusion_plating_jobs for the four
# models that moved: fp.work.role, fp.operator.proficiency,
# fp.qc.checklist.template (+line), fp.job.consumption.
#
# Pre-migration so Odoo's normal load pass sees the records under the
# new module owner, not as orphans pending deletion.
#
# Idempotent — `ON CONFLICT DO NOTHING` skips rows already migrated.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
moves = [
# (xmlid pattern, list of model identifiers to move)
('model_fp_job_consumption',),
# ACL records (csv:id values get prefixed with the owning module)
('access_fp_job_consumption_%',),
]
for (pat,) in moves:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating_jobs'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating_jobs'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d ir.model.data rows matching %s -> fusion_plating_jobs",
cr.rowcount, pat,
)
# Views, actions, menus that the old module created
view_patterns = [
'view_fp_job_consumption_%',
'action_fp_job_consumption%',
'menu_fp_job_consumption%',
]
for pat in view_patterns:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating_jobs'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating_jobs'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_jobs",
cr.rowcount, pat,
)
# Phase 1 swap: fp.job.consumption columns. Drop the legacy
# MRP-pointing columns (production_id, workorder_id) from the
# already-existing table — there are zero rows referencing MRP, and
# the new model declares job_id / step_id instead.
cr.execute(
"""
ALTER TABLE fp_job_consumption
DROP COLUMN IF EXISTS production_id,
DROP COLUMN IF EXISTS workorder_id
"""
)
_logger.info("Sub 11: dropped MRP columns on fp_job_consumption")

View File

@@ -27,3 +27,12 @@ from . import fusion_plating_kpi_value
# Phase 5 — Job Margin report.
from . import report_fp_job_margin
# Phase 1 of MRP cut-out (Sub 11) — relocated from fusion_plating_bridge_mrp.
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
# back on jobs without a cycle.)
from . import fp_job_consumption
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
# hr.employee shop-roles inherit live in fusion_plating core so every
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a
# transitive dep on jobs.

View File

@@ -517,6 +517,12 @@ class FpJob(models.Model):
if self.env.context.get('fp_jobs_migration'):
return result
for job in self:
# Auto-generate steps from the recipe — was previously only
# called by seed scripts, which meant real-life confirmed
# jobs sat with zero operations. Idempotent: the generator
# short-circuits when steps already exist.
if job.recipe_id and not job.step_ids:
job._generate_steps_from_recipe()
job._fp_create_portal_job()
job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection()
@@ -526,36 +532,28 @@ class FpJob(models.Model):
def _fp_create_racking_inspection(self):
"""Auto-create a draft racking inspection on job confirm.
Mirrors bridge_mrp's behaviour for MO confirm. Best-effort: the
legacy fp.racking.inspection model still requires a production_id
(mrp.production), so we can only create one when this job is
bound to an MO via bridge_mrp. Otherwise we skip cleanly — Phase
9 will flip the required-FK to fp.job.
Phase 9 — production_id is now optional on fp.racking.inspection,
so we always create one bound by `x_fc_job_id`. When the job is
also linked to an MO (legacy bridge_mrp coexistence), populate
production_id too so legacy reports keep working.
Idempotent — if an inspection already exists for this job, skip.
"""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
return
Inspection = self.env['fp.racking.inspection'].sudo()
# The model still requires production_id today. If the job has
# no MO link (which it won't in pure-native mode), skip rather
# than crash. The link exists when fusion_plating_bridge_mrp is
# installed and a production was created in parallel.
production = False
if 'production_id' in self._fields and self.production_id:
production = self.production_id
elif 'mrp_production_id' in self._fields and getattr(
self, 'mrp_production_id', False):
production = self.mrp_production_id
if not production:
_logger.debug(
"Job %s: no MO link — skipping racking-inspection auto-create "
"(required production_id not yet on fp.job).", self.name,
)
if 'x_fc_job_id' not in Inspection._fields:
# Schema not yet upgraded — skip.
return
existing = Inspection.search([
('x_fc_job_id', '=', self.id),
], limit=1)
if existing:
return
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
vals = {'x_fc_job_id': self.id}
try:
vals = {'production_id': production.id}
if 'x_fc_job_id' in Inspection._fields:
vals['x_fc_job_id'] = self.id
Inspection.create(vals)
except Exception as e:
_logger.warning(

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# MRP-flavoured fields (production_id, workorder_id) replaced by their
# native fp.job / fp.job.step equivalents.
from odoo import api, fields, models, _
class FpJobConsumption(models.Model):
"""A single consumable drawdown charged to a plating job.
Sources include bath replenishment applied against a job, masking tape
rolls, PPE, nickel salts — anything that has a cost and should roll
into job costing.
Kept deliberately lightweight: one row per event, cost derived from
`product.standard_price` at log time (snapshot, not reactive).
"""
_name = 'fp.job.consumption'
_description = 'Fusion Plating — Job Consumption'
_order = 'logged_date desc, id desc'
job_id = fields.Many2one(
'fp.job', string='Plating Job',
required=True, ondelete='cascade', index=True,
)
step_id = fields.Many2one(
'fp.job.step', string='Job Step',
domain="[('job_id', '=', job_id)]",
ondelete='set null',
)
product_id = fields.Many2one(
'product.product', string='Product', required=True,
domain="[('sale_ok', '=', False)]",
)
product_name = fields.Char(
string='Product Name (snapshot)',
help='Free-text product label if no inventory product is linked.',
)
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
uom_id = fields.Many2one(
'uom.uom', string='UoM',
)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
unit_cost = fields.Monetary(
string='Unit Cost (snapshot)', currency_field='currency_id',
help='Taken from product.standard_price at log time.',
)
total_cost = fields.Monetary(
string='Total Cost', currency_field='currency_id',
compute='_compute_total_cost', store=True,
)
logged_date = fields.Datetime(
string='Logged', default=fields.Datetime.now,
)
logged_by_id = fields.Many2one(
'res.users', string='Logged By', default=lambda self: self.env.user,
)
source = fields.Selection(
[('replenishment', 'Bath Replenishment'),
('masking', 'Masking Material'),
('ppe', 'PPE / Consumables'),
('chemistry', 'Process Chemistry'),
('other', 'Other')],
string='Source', default='other', required=True,
)
replenishment_id = fields.Many2one(
'fusion.plating.bath.replenishment.suggestion',
string='Replenishment Suggestion',
ondelete='set null',
)
notes = fields.Char(string='Notes')
@api.depends('quantity', 'unit_cost')
def _compute_total_cost(self):
for rec in self:
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
@api.depends('product_id', 'product_name', 'quantity', 'job_id')
def _compute_display_name(self):
for rec in self:
label = rec.product_id.display_name or rec.product_name or 'Consumption'
qty = ('%g' % rec.quantity) if rec.quantity else ''
job = rec.job_id.name or ''
bits = [label]
if qty:
bits.append('×' + qty)
if job:
bits.append('(%s)' % job)
rec.display_name = ' '.join(bits)
@api.onchange('product_id')
def _onchange_product(self):
if self.product_id:
self.product_name = self.product_id.display_name
self.unit_cost = self.product_id.standard_price or 0.0
self.uom_id = self.product_id.uom_id or False

View File

@@ -9,7 +9,7 @@
# bridge_mrp keeps its version alive so legacy MO-flow keeps working.
# Both coexist during the migration period.
from odoo import fields, models
from odoo import api, fields, models
class FpJobNodeOverride(models.Model):
@@ -35,6 +35,14 @@ class FpJobNodeOverride(models.Model):
help='When True, this opt-in/out node is included in step generation.',
)
@api.depends('job_id', 'node_id', 'included')
def _compute_display_name(self):
for rec in self:
job = rec.job_id.display_name or '(no job)'
node = rec.node_id.display_name or '(no node)'
tag = 'included' if rec.included else 'excluded'
rec.display_name = '%s · %s [%s]' % (job, node, tag)
_unique_job_node = models.Constraint(
'unique(job_id, node_id)',
'A job can only have one override per recipe node.',

View File

@@ -13,6 +13,34 @@ from odoo.exceptions import UserError
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
def button_start(self):
"""Override — soft gate when parts haven't been received yet.
Doesn't block (parts could be in-transit late, manager wants
the shop to start prep regardless), but posts a chatter warning
on the job so the audit trail captures premature starts.
"""
result = super().button_start()
for step in self:
so = step.job_id.sale_order_id
if not so:
continue
recv = so.x_fc_receiving_status if (
'x_fc_receiving_status' in so._fields
) else None
if recv in (False, None, 'not_received'):
step.job_id.message_post(body=_(
'Step "%(step)s" started before parts were received '
'(SO %(so)s — receiving status: %(status)s). '
'Confirm the parts are physically on the floor before '
'continuing.'
) % {
'step': step.name,
'so': so.name or '',
'status': recv or 'unknown',
})
return result
def button_pause(self):
"""Pause an in-progress step (operator break, end of shift).

View File

@@ -2,18 +2,55 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job link on fp.racking.inspection.
# Coexists with the legacy production_id (mrp.production) link.
# Phase 3 / Phase 9 — native-job link on fp.racking.inspection.
# Coexists with the legacy production_id (mrp.production) link; either
# (or both) may be set.
from odoo import fields, models
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class FpRackingInspection(models.Model):
_inherit = 'fp.racking.inspection'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
help='Native fp.job link. Coexists with the legacy production_id.',
)
# x_fc_job_id is declared in the base receiving module so its views
# can reference it. We add help/depends here.
@api.depends('x_fc_job_id.name', 'partner_id.name')
def _compute_name(self):
for rec in self:
if rec.x_fc_job_id:
rec.name = _('Inspection — %s') % rec.x_fc_job_id.name
else:
rec.name = _('Racking Inspection')
@api.depends('x_fc_job_id.sale_order_id')
def _compute_sale_order(self):
for rec in self:
so = (rec.x_fc_job_id.sale_order_id
if rec.x_fc_job_id and rec.x_fc_job_id.sale_order_id
else False)
rec.sale_order_id = so or False
rec.partner_id = so.partner_id if so else False
@api.constrains('x_fc_job_id')
def _check_link_present(self):
for rec in self:
if not rec.x_fc_job_id:
raise ValidationError(_(
'Racking inspection must reference a plating job.'
))
@api.constrains('x_fc_job_id')
def _check_job_unique(self):
for rec in self:
if not rec.x_fc_job_id:
continue
dup = self.search_count([
('x_fc_job_id', '=', rec.x_fc_job_id.id),
('id', '!=', rec.id),
])
if dup:
raise ValidationError(_(
'Only one racking inspection per plating job.'
))

View File

@@ -10,6 +10,9 @@
# bridge_mrp's MO-creation hook handles the flow.
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
@@ -18,6 +21,147 @@ _logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_fc_fp_job_count = fields.Integer(
string='Plating Jobs',
compute='_compute_fp_job_count',
)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
# relocated from fusion_plating_bridge_mrp. Field re-declared with
# the same selection + compute pointer; jobs is now the source of
# truth so Phase 5 can delete bridge_mrp without losing the field.
# ------------------------------------------------------------------
x_fc_workflow_stage = fields.Selection(
[
('draft', 'Quote'),
('awaiting_parts', 'Parts'),
('inspecting', 'Inspecting'),
('accept_parts', 'Accept'),
('assign_work', 'Assign'),
('in_production', 'Production'),
('ready_to_ship', 'Ready'),
('shipped', 'Shipped'),
('invoicing', 'Invoicing'),
('paid', 'Paid'),
('complete', 'Done'),
('cancelled', 'Cancelled'),
],
compute='_compute_workflow_stage',
string='Workflow Stage',
help='Current position in the SO → Ship → Invoice workflow. '
'Drives which next-step button is shown on the SO header.',
)
x_fc_assigned_manager_id = fields.Many2one(
'res.users', string='Assigned Manager',
help='The manager responsible for this job. Set when the job '
'is confirmed (falls back to the salesperson).',
tracking=True,
)
def _compute_fp_job_count(self):
Job = self.env['fp.job'].sudo()
for so in self:
so.x_fc_fp_job_count = Job.search_count(
[('sale_order_id', '=', so.id)]
)
def _compute_workflow_stage(self):
"""Native-jobs override — walks fp.job state instead of mrp.production.
When `use_native_jobs` is on, the SO is fulfilled by `fp.job`
records, not MRP MOs. The bridge_mrp compute reads `mrp.production`
and would falsely stall the banner. We branch at the top: native
mode → fp.job walker; legacy mode → super() (bridge_mrp).
"""
ICP = self.env['ir.config_parameter'].sudo()
native = ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True'
if not native:
return super()._compute_workflow_stage()
Job = self.env['fp.job']
Delivery = self.env.get('fusion.plating.delivery')
for so in self:
if so.state == 'cancel':
so.x_fc_workflow_stage = 'cancelled'
continue
if so.state in ('draft', 'sent'):
so.x_fc_workflow_stage = 'draft'
continue
jobs = Job.search([('sale_order_id', '=', so.id)])
all_jobs_done = bool(jobs) and all(
j.state == 'done' for j in jobs
)
shipped = False
if Delivery is not None and jobs:
if 'x_fc_job_id' in Delivery._fields:
shipped = bool(Delivery.search_count([
('x_fc_job_id', 'in', jobs.ids),
('state', '=', 'delivered'),
]))
posted_invoices = so.invoice_ids.filtered(
lambda i: i.state == 'posted'
)
has_posted_invoice = bool(posted_invoices)
all_paid = has_posted_invoice and all(
i.payment_state in ('paid', 'in_payment')
for i in posted_invoices
)
if shipped and all_paid:
so.x_fc_workflow_stage = 'complete'
continue
if all_paid and not shipped:
so.x_fc_workflow_stage = 'paid'
continue
if shipped and has_posted_invoice:
so.x_fc_workflow_stage = 'invoicing'
continue
if shipped:
so.x_fc_workflow_stage = 'shipped'
continue
if all_jobs_done:
so.x_fc_workflow_stage = 'ready_to_ship'
continue
recv_status = so.x_fc_receiving_status or 'not_received'
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status in ('partial', 'received'):
so.x_fc_workflow_stage = 'inspecting'
continue
if recv_status == 'inspected':
if not so.x_fc_assigned_manager_id and not jobs:
so.x_fc_workflow_stage = 'assign_work'
continue
so.x_fc_workflow_stage = 'in_production'
continue
so.x_fc_workflow_stage = (
'in_production' if jobs else 'awaiting_parts'
)
def action_view_fp_jobs(self):
self.ensure_one()
jobs = self.env['fp.job'].search([('sale_order_id', '=', self.id)])
action = {
'type': 'ir.actions.act_window',
'name': _('Plating Jobs'),
'res_model': 'fp.job',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(jobs) == 1:
action.update({
'view_mode': 'form',
'res_id': jobs.id,
})
return action
def action_confirm(self):
result = super().action_confirm()
# Only run when the native flag is on
@@ -25,6 +169,22 @@ class SaleOrder(models.Model):
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
for so in self:
so._fp_auto_create_job()
# Auto-confirm any draft jobs we just created so steps
# generate immediately (no manager click required).
# Best-effort: an exception in side-effects shouldn't
# block the SO confirm itself.
draft_jobs = self.env['fp.job'].sudo().search([
('sale_order_id', '=', so.id),
('state', '=', 'draft'),
])
for job in draft_jobs:
try:
job.action_confirm()
except Exception as exc:
so.message_post(body=_(
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
'Confirm manually from the job form.'
) % {'job': job.name, 'err': exc})
return result
def _fp_auto_create_job(self):
@@ -121,3 +281,94 @@ class SaleOrder(models.Model):
self.name, job.name, qty, (recipe.name if recipe else '-'),
)
return True
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow stage action buttons.
# Native versions of bridge_mrp's action_fp_* methods. Drop the
# mrp.production lookups; talk to fp.job and fp.receiving directly.
# ------------------------------------------------------------------
def action_fp_mark_inspected(self):
"""Flip open receivings from draft → inspecting."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state == 'draft':
rec.state = 'inspecting'
self.message_post(body=_('Parts marked as inspecting.'))
return True
def action_fp_accept_parts(self):
"""Mark receiving accepted; flip SO receiving status to inspected."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft', 'inspecting'):
rec.state = 'accepted'
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'inspected'
self.message_post(body=_('Parts accepted — ready to assign manager.'))
return True
def action_fp_assign_to_me(self):
"""Manager claims the SO and confirms its draft fp.jobs."""
self.ensure_one()
user = self.env.user
self.x_fc_assigned_manager_id = user.id
Job = self.env['fp.job']
jobs = Job.search([
('sale_order_id', '=', self.id),
('state', '=', 'draft'),
])
for job in jobs:
try:
job.action_confirm()
except Exception as exc:
self.message_post(body=_(
'Auto-confirm of fp.job %s failed: %s'
) % (job.name, exc))
if 'manager_id' in job._fields and not job.manager_id:
job.manager_id = user.id
self.message_post(
body=Markup(_(
'Job assigned to <b>%s</b>. %d plating job(s) released to the floor.'
)) % (user.name, len(jobs)),
)
return True
def action_fp_mark_shipped(self):
"""Mark linked deliveries delivered (triggers auto-invoice)."""
self.ensure_one()
Delivery = self.env.get('fusion.plating.delivery')
if Delivery is None:
return False
Job = self.env['fp.job']
jobs = Job.search([('sale_order_id', '=', self.id)])
deliveries = Delivery.browse([])
if 'x_fc_job_id' in Delivery._fields:
deliveries = Delivery.search([
('x_fc_job_id', 'in', jobs.ids),
('state', '!=', 'delivered'),
])
for dlv in deliveries:
dlv.action_mark_delivered()
self.message_post(
body=_(
'%d delivery record(s) marked delivered. '
'Invoice flow triggered per invoice strategy.'
) % len(deliveries),
)
return True
def action_fp_open_shop_floor(self):
"""Jump to the Plant Overview filtered to this SO's jobs."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_plant_overview',
'name': _('Shop Floor — %s') % self.name,
'target': 'current',
}

View File

@@ -2,3 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_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_fp_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_fp_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,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_job_node_override_operator fp.job.node.override.operator model_fp_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_job_node_override_supervisor fp.job.node.override.supervisor model_fp_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_job_node_override_manager fp.job.node.override.manager model_fp_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_job_consumption_operator fp.job.consumption.operator model_fp_job_consumption fusion_plating.group_fusion_plating_operator 1 1 1 0
6 access_fp_job_consumption_supervisor fp.job.consumption.supervisor model_fp_job_consumption fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_job_consumption_manager fp.job.consumption.manager model_fp_job_consumption fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_consumption_list" model="ir.ui.view">
<field name="name">fp.job.consumption.list</field>
<field name="model">fp.job.consumption</field>
<field name="arch" type="xml">
<list editable="bottom" default_order="logged_date desc">
<field name="logged_date"/>
<field name="job_id"/>
<field name="step_id"/>
<field name="product_id"/>
<field name="quantity"/>
<field name="uom_id"/>
<field name="currency_id" column_invisible="1"/>
<field name="unit_cost" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="total_cost" widget="monetary"
options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="source"/>
<field name="logged_by_id" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_job_consumption_form" model="ir.ui.view">
<field name="name">fp.job.consumption.form</field>
<field name="model">fp.job.consumption</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="job_id"/>
<field name="step_id"/>
<field name="product_id"/>
<field name="product_name"/>
<field name="source"/>
</group>
<group>
<field name="quantity"/>
<field name="uom_id"/>
<field name="currency_id"/>
<field name="unit_cost" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="total_cost" widget="monetary"
options="{'currency_field': 'currency_id'}" readonly="1"/>
<field name="logged_date"/>
<field name="logged_by_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_job_consumption" model="ir.actions.act_window">
<field name="name">Job Consumables Log</field>
<field name="res_model">fp.job.consumption</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 3 (Sub 11) — native Production Priorities kanban / list on
fp.job.step. Replaces the bridge_mrp version that bound to
mrp.workorder. Same UX (drag-drop ordering across work centres,
list with handle, badges by state).
-->
<odoo>
<record id="view_fp_step_priority_kanban" model="ir.ui.view">
<field name="name">fp.job.step.priority.kanban</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<kanban default_group_by="work_centre_id" default_order="sequence, id">
<field name="name"/>
<field name="work_centre_id"/>
<field name="job_id"/>
<field name="state"/>
<field name="sequence"/>
<field name="duration_actual"/>
<field name="duration_expected"/>
<field name="assigned_user_id"/>
<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="job_id"/>
</strong>
</div>
</div>
<div class="o_kanban_record_body">
<div><strong><field name="name"/></strong></div>
<div class="text-muted">
<t t-if="record.assigned_user_id.raw_value">
<field name="assigned_user_id"/>
</t>
<t t-if="record.duration_actual.raw_value">
<field name="duration_actual" 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 == 'in_progress'"
decoration-muted="state == 'paused'"
decoration-success="state == 'done'"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_fp_step_priority_list" model="ir.ui.view">
<field name="name">fp.job.step.priority.list</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<list default_order="sequence, id">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="job_id"/>
<field name="work_centre_id"/>
<field name="assigned_user_id"/>
<field name="duration_expected" widget="float_time"/>
<field name="duration_actual" widget="float_time"/>
<field name="state" widget="badge"
decoration-info="state == 'ready'"
decoration-warning="state == 'in_progress'"
decoration-muted="state == 'paused'"
decoration-success="state == 'done'"/>
</list>
</field>
</record>
<record id="action_fp_step_priority" model="ir.actions.act_window">
<field name="name">Production Priorities</field>
<field name="res_model">fp.job.step</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('state', 'in', ['pending', 'ready', 'in_progress', 'paused'])]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_fp_step_priority_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_fp_step_priority_list')})]"/>
</record>
<menuitem id="menu_fp_step_priority"
name="Production Priorities"
parent="fusion_plating.menu_fp_operations"
action="action_fp_step_priority"
sequence="10"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Adds the SO → fp.job smart button so the SO form is a hub for the
native job lifecycle (replaces the legacy MO smart button when the
use_native_jobs flag is on).
-->
<odoo>
<record id="view_sale_order_form_fp_jobs" model="ir.ui.view">
<field name="name">sale.order.form.fp.jobs</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- After the legacy Manufacturing button (added by bridge_mrp).
Always visible (no invisible-on-zero) so users can navigate
from the SO even before jobs exist. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_fp_jobs" type="object"
class="oe_stat_button" icon="fa-cogs">
<field name="x_fc_fp_job_count" widget="statinfo"
string="Plating Jobs"/>
</button>
</xpath>
</field>
</record>
</odoo>