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:
gsinghpal
2026-04-25 04:49:44 -04:00
parent f8ad224b1a
commit 7f84e66b72
8 changed files with 245 additions and 1 deletions

View File

@@ -3,9 +3,15 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.2.4.0',
'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating',
'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': """
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_receiving', # fp.racking.inspection (Phase 3)
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
'fusion_plating_shopfloor', # legacy menus restricted in views/legacy_menu_hide.xml
],
'data': [
'security/legacy_groups.xml',
'security/ir.model.access.csv',
'views/res_config_settings_views.xml',
'views/job_process_tree_action.xml',
'views/job_overview_actions.xml',
'views/job_tablet_action.xml',
'views/fp_job_form_inherit.xml',
'views/legacy_menu_hide.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_job_margin.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -6,6 +6,7 @@
# task-by-task in Tasks 2.2 onwards.
from . import fp_job
from . import fp_job_step
from . import fp_job_node_override
from . import fp_portal_job
from . import account_move
@@ -23,3 +24,6 @@ from . import fp_racking_inspection
# Phase 4 — light refactors batch B (notifications, KPI source tag).
from . import fp_notification_trigger
from . import fusion_plating_kpi_value
# Phase 5 — Job Margin report.
from . import report_fp_job_margin

View File

@@ -26,22 +26,27 @@ class FpJob(models.Model):
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
ondelete='restrict',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
ondelete='restrict',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
ondelete='set null',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
ondelete='set null',
)
delivery_id = fields.Many2one(
'fusion.plating.delivery',
string='Delivery',
ondelete='set null',
)
override_ids = fields.One2many(
'fp.job.node.override',

View 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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>