feat(jobs): finish original plan — Job Margin, polish, legacy hide
Three batched changes that close out the original 10-phase
migration plan.
1. Phase 5 — Job Margin report bound to fp.job (replaces the
mrp.production-bound report_wo_margin). Per-step labour cost
table + margin summary using existing fp.job.step.cost_total
from Phase 1.
2. Polish:
- Real implementations for fp.job.step.button_pause,
button_skip, button_cancel (was NotImplementedError stubs).
button_pause closes the open timelog and sums duration_actual,
mirroring button_finish; button_skip/cancel transition state
with UserError guards.
- Explicit ondelete= policies on fp.job's cross-module Many2ones
(part_catalog/coating restrict, customer_spec/portal/delivery
set null) — was implicit set null.
- Standard Nexa Systems author/website/maintainer/support block
on fusion_plating_jobs manifest, suppressing the install
warning.
3. Legacy hide:
- New 'Plating Legacy Menus' group (group_fusion_plating_legacy_menus)
— nobody in it by default.
- Old shopfloor Manager Desk + Plant Overview + Tablet Station
menus restricted to that group, hiding them from operators
now that the native equivalents under 'Plating Jobs (Native)'
exist. (Note: ir.ui.menu uses group_ids in Odoo 19, not the
deprecated groups_id alias.)
Manifest 19.0.2.4.0 → 19.0.3.0.0. fusion_plating_shopfloor added
to depends so the legacy menu xmlid references resolve at install
time.
Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,15 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.2.4.0',
|
'version': '19.0.3.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
|
'author': 'Nexa Systems Inc.',
|
||||||
|
'website': 'https://www.nexasystems.ca',
|
||||||
|
'maintainer': 'Nexa Systems Inc.',
|
||||||
|
'support': 'support@nexasystems.ca',
|
||||||
|
'price': 0.00,
|
||||||
|
'currency': 'CAD',
|
||||||
'description': """
|
'description': """
|
||||||
Native Plating Job Bridge
|
Native Plating Job Bridge
|
||||||
=========================
|
=========================
|
||||||
@@ -34,16 +40,20 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'fusion_plating_quality', # fusion.plating.customer.spec, fusion.plating.quality.hold
|
'fusion_plating_quality', # fusion.plating.customer.spec, fusion.plating.quality.hold
|
||||||
'fusion_plating_receiving', # fp.racking.inspection (Phase 3)
|
'fusion_plating_receiving', # fp.racking.inspection (Phase 3)
|
||||||
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
|
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
|
||||||
|
'fusion_plating_shopfloor', # legacy menus restricted in views/legacy_menu_hide.xml
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
|
'security/legacy_groups.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/job_process_tree_action.xml',
|
'views/job_process_tree_action.xml',
|
||||||
'views/job_overview_actions.xml',
|
'views/job_overview_actions.xml',
|
||||||
'views/job_tablet_action.xml',
|
'views/job_tablet_action.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
|
'views/legacy_menu_hide.xml',
|
||||||
'report/report_fp_job_sticker.xml',
|
'report/report_fp_job_sticker.xml',
|
||||||
'report/report_fp_job_traveller.xml',
|
'report/report_fp_job_traveller.xml',
|
||||||
|
'report/report_fp_job_margin.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
# task-by-task in Tasks 2.2 onwards.
|
# task-by-task in Tasks 2.2 onwards.
|
||||||
|
|
||||||
from . import fp_job
|
from . import fp_job
|
||||||
|
from . import fp_job_step
|
||||||
from . import fp_job_node_override
|
from . import fp_job_node_override
|
||||||
from . import fp_portal_job
|
from . import fp_portal_job
|
||||||
from . import account_move
|
from . import account_move
|
||||||
@@ -23,3 +24,6 @@ from . import fp_racking_inspection
|
|||||||
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
# Phase 4 — light refactors batch B (notifications, KPI source tag).
|
||||||
from . import fp_notification_trigger
|
from . import fp_notification_trigger
|
||||||
from . import fusion_plating_kpi_value
|
from . import fusion_plating_kpi_value
|
||||||
|
|
||||||
|
# Phase 5 — Job Margin report.
|
||||||
|
from . import report_fp_job_margin
|
||||||
|
|||||||
@@ -26,22 +26,27 @@ class FpJob(models.Model):
|
|||||||
part_catalog_id = fields.Many2one(
|
part_catalog_id = fields.Many2one(
|
||||||
'fp.part.catalog',
|
'fp.part.catalog',
|
||||||
string='Part',
|
string='Part',
|
||||||
|
ondelete='restrict',
|
||||||
)
|
)
|
||||||
coating_config_id = fields.Many2one(
|
coating_config_id = fields.Many2one(
|
||||||
'fp.coating.config',
|
'fp.coating.config',
|
||||||
string='Coating Configuration',
|
string='Coating Configuration',
|
||||||
|
ondelete='restrict',
|
||||||
)
|
)
|
||||||
customer_spec_id = fields.Many2one(
|
customer_spec_id = fields.Many2one(
|
||||||
'fusion.plating.customer.spec',
|
'fusion.plating.customer.spec',
|
||||||
string='Customer Spec',
|
string='Customer Spec',
|
||||||
|
ondelete='set null',
|
||||||
)
|
)
|
||||||
portal_job_id = fields.Many2one(
|
portal_job_id = fields.Many2one(
|
||||||
'fusion.plating.portal.job',
|
'fusion.plating.portal.job',
|
||||||
string='Portal Job',
|
string='Portal Job',
|
||||||
|
ondelete='set null',
|
||||||
)
|
)
|
||||||
delivery_id = fields.Many2one(
|
delivery_id = fields.Many2one(
|
||||||
'fusion.plating.delivery',
|
'fusion.plating.delivery',
|
||||||
string='Delivery',
|
string='Delivery',
|
||||||
|
ondelete='set null',
|
||||||
)
|
)
|
||||||
override_ids = fields.One2many(
|
override_ids = fields.One2many(
|
||||||
'fp.job.node.override',
|
'fp.job.node.override',
|
||||||
|
|||||||
62
fusion_plating/fusion_plating_jobs/models/fp_job_step.py
Normal file
62
fusion_plating/fusion_plating_jobs/models/fp_job_step.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# Real implementations for the state-machine action stubs that
|
||||||
|
# fusion_plating core's fp.job.step shipped as NotImplementedError
|
||||||
|
# placeholders. Per spec §5.2 state machine.
|
||||||
|
|
||||||
|
from odoo import _, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class FpJobStep(models.Model):
|
||||||
|
_inherit = 'fp.job.step'
|
||||||
|
|
||||||
|
def button_pause(self):
|
||||||
|
"""Pause an in-progress step (operator break, end of shift).
|
||||||
|
|
||||||
|
Closes the open timelog row, sums duration_actual, transitions
|
||||||
|
state to 'paused'. button_start re-opens a fresh timelog when
|
||||||
|
the operator resumes.
|
||||||
|
"""
|
||||||
|
for step in self:
|
||||||
|
if step.state != 'in_progress':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is in state '%s' — only in-progress steps can pause."
|
||||||
|
) % (step.name, step.state))
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||||
|
if open_log:
|
||||||
|
open_log.write({'date_finished': now})
|
||||||
|
step.state = 'paused'
|
||||||
|
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def button_skip(self):
|
||||||
|
"""Skip a pending/ready step (e.g. opt-in step the planner
|
||||||
|
decided not to activate for this job).
|
||||||
|
"""
|
||||||
|
for step in self:
|
||||||
|
if step.state not in ('pending', 'ready'):
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is in state '%s' — only pending/ready steps can be skipped."
|
||||||
|
) % (step.name, step.state))
|
||||||
|
step.state = 'skipped'
|
||||||
|
return True
|
||||||
|
|
||||||
|
def button_cancel(self):
|
||||||
|
"""Cancel a single step. Use fp.job.action_cancel to cancel
|
||||||
|
the whole job.
|
||||||
|
"""
|
||||||
|
for step in self:
|
||||||
|
if step.state == 'done':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is done — cannot cancel."
|
||||||
|
) % step.name)
|
||||||
|
if step.state == 'cancelled':
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' is already cancelled."
|
||||||
|
) % step.name)
|
||||||
|
step.state = 'cancelled'
|
||||||
|
return True
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# Native fp.job margin report — replaces report_wo_margin which binds
|
||||||
|
# to mrp.production. Uses fp.job.step.cost_total (already computed in
|
||||||
|
# Phase 1: duration_actual / 60 * cost_per_hour).
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class ReportFpJobMargin(models.AbstractModel):
|
||||||
|
_name = 'report.fusion_plating_jobs.report_fp_job_margin'
|
||||||
|
_description = 'Plating Job Margin Report'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_report_values(self, docids, data=None):
|
||||||
|
Job = self.env['fp.job']
|
||||||
|
jobs = Job.browse(docids)
|
||||||
|
rows = []
|
||||||
|
for job in jobs:
|
||||||
|
step_rows = []
|
||||||
|
total_labour = 0.0
|
||||||
|
total_minutes = 0.0
|
||||||
|
for step in job.step_ids.sorted('sequence'):
|
||||||
|
step_rows.append({
|
||||||
|
'sequence': step.sequence,
|
||||||
|
'name': step.name,
|
||||||
|
'work_centre': step.work_centre_id.name if step.work_centre_id else '-',
|
||||||
|
'duration_expected': step.duration_expected,
|
||||||
|
'duration_actual': step.duration_actual,
|
||||||
|
'rate': step.cost_per_hour,
|
||||||
|
'cost': step.cost_total,
|
||||||
|
})
|
||||||
|
total_labour += step.cost_total
|
||||||
|
total_minutes += step.duration_actual
|
||||||
|
rows.append({
|
||||||
|
'job': job,
|
||||||
|
'steps': step_rows,
|
||||||
|
'total_minutes': total_minutes,
|
||||||
|
'total_labour': total_labour,
|
||||||
|
'quoted_revenue': job.quoted_revenue,
|
||||||
|
'actual_cost': job.actual_cost,
|
||||||
|
'margin': job.margin,
|
||||||
|
'margin_pct': job.margin_pct,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'doc_ids': docids,
|
||||||
|
'doc_model': 'fp.job',
|
||||||
|
'docs': jobs,
|
||||||
|
'rows': rows,
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_report_fp_job_margin" model="ir.actions.report">
|
||||||
|
<field name="name">Job Margin Report</field>
|
||||||
|
<field name="model">fp.job</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">fusion_plating_jobs.report_fp_job_margin_template</field>
|
||||||
|
<field name="report_file">fusion_plating_jobs.report_fp_job_margin_template</field>
|
||||||
|
<field name="print_report_name">'Job Margin - %s' % (object.name or '').replace('/', '-')</field>
|
||||||
|
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<template id="report_fp_job_margin_template">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="rows" t-as="row">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page">
|
||||||
|
<h2>Job Margin — <span t-esc="row['job'].name"/></h2>
|
||||||
|
<table class="table table-sm" style="margin-top: 1em; max-width: 600px;">
|
||||||
|
<tr><th>Customer</th><td><span t-esc="row['job'].partner_id.name"/></td></tr>
|
||||||
|
<tr><th>Recipe</th><td><span t-esc="row['job'].recipe_id.name or '-'"/></td></tr>
|
||||||
|
<tr><th>Quantity</th><td><span t-esc="row['job'].qty"/></td></tr>
|
||||||
|
<tr><th>Status</th><td><span t-esc="row['job'].state"/></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 1.5em;">Step Breakdown</h3>
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Step</th>
|
||||||
|
<th>Work Centre</th>
|
||||||
|
<th class="text-end">Expected (min)</th>
|
||||||
|
<th class="text-end">Actual (min)</th>
|
||||||
|
<th class="text-end">Rate / hr</th>
|
||||||
|
<th class="text-end">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="row['steps']" t-as="step">
|
||||||
|
<tr>
|
||||||
|
<td><span t-esc="step['sequence']"/></td>
|
||||||
|
<td><span t-esc="step['name']"/></td>
|
||||||
|
<td><span t-esc="step['work_centre']"/></td>
|
||||||
|
<td class="text-end"><span t-esc="step['duration_expected']"/></td>
|
||||||
|
<td class="text-end"><span t-esc="step['duration_actual']"/></td>
|
||||||
|
<td class="text-end"><span t-field="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||||
|
<td class="text-end"><span t-field="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
<tr style="font-weight: bold; background: #f3f3f3;">
|
||||||
|
<td colspan="3">Totals</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end"><span t-esc="row['total_minutes']"/></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end"><span t-field="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 1.5em;">Margin Summary</h3>
|
||||||
|
<table class="table table-sm" style="max-width: 400px;">
|
||||||
|
<tr><th>Quoted Revenue</th><td class="text-end"><span t-field="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||||
|
<tr><th>Actual Cost</th><td class="text-end"><span t-field="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||||
|
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-field="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
|
||||||
|
<tr><th>Margin %</th><td class="text-end"><span t-esc="round(row['margin_pct'], 1)"/>%</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo noupdate="0">
|
||||||
|
<!-- Hidden group used to gate legacy MO/WO menus that have been
|
||||||
|
replaced by fp.job equivalents. Nobody is in this group by
|
||||||
|
default, so the legacy menus are invisible to all users. An
|
||||||
|
admin can manually add themselves via Settings > Users if
|
||||||
|
they need to access historical MO/WO data. -->
|
||||||
|
<record id="group_fusion_plating_legacy_menus" model="res.groups">
|
||||||
|
<field name="name">Plating Legacy Menus</field>
|
||||||
|
<field name="comment">Internal group to hide legacy MO/WO menus that have been replaced by the native fp.job model. Add a user to this group only if they need to navigate historical mrp.production / mrp.workorder records directly.</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo noupdate="0">
|
||||||
|
<!-- Restrict legacy MO/WO-bound menus to the hidden 'Plating Legacy
|
||||||
|
Menus' group. The native fp.job menus (under Plating Jobs
|
||||||
|
(Native)) replace these. Original operations menus that don't
|
||||||
|
have a native equivalent yet are left alone.
|
||||||
|
|
||||||
|
List intentionally narrow: only menus that have a CLEAR fp.job
|
||||||
|
replacement get hidden. Operations like Recipes, Baths, Tanks,
|
||||||
|
etc. stay visible because there's no native replacement yet. -->
|
||||||
|
|
||||||
|
<!-- fusion_plating_shopfloor: legacy Manager Desk, Plant Overview,
|
||||||
|
Tablet Station — replaced by Manager Dashboard (Native), Plant
|
||||||
|
Overview (Native), Tablet Station (Native) under
|
||||||
|
Plating Jobs (Native). -->
|
||||||
|
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
|
||||||
|
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
|
||||||
|
</record>
|
||||||
|
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
|
||||||
|
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
|
||||||
|
</record>
|
||||||
|
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
|
||||||
|
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user