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>
550 lines
22 KiB
Python
550 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
#
|
|
# fp.job extension — cross-module fields that couldn't live in core
|
|
# because their target models are in dependent modules. Per spec §5.1
|
|
# this module is the umbrella that re-bundles the cross-module
|
|
# extensions for the native job flow.
|
|
#
|
|
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
|
|
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
|
|
|
|
import logging
|
|
|
|
from markupsafe import Markup
|
|
|
|
from odoo import fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FpJob(models.Model):
|
|
_inherit = 'fp.job'
|
|
|
|
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',
|
|
'job_id',
|
|
string='Recipe Overrides',
|
|
)
|
|
# Phase 7 — migration idempotency key. Populated by
|
|
# scripts/migrate_to_fp_jobs.py to mark a fp.job as the mirror of a
|
|
# specific mrp.production. Used to skip already-migrated MOs on
|
|
# subsequent runs. Cleared after the 2-week shadow period.
|
|
legacy_mrp_production_id = fields.Integer(
|
|
string='Legacy MRP Production ID',
|
|
index=True,
|
|
help='Database id of the source mrp.production record this job '
|
|
'was migrated from. Used by the migration script for '
|
|
'idempotency. Cleared post-cutover.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Recipe → fp.job.step generation (Task 2.4)
|
|
#
|
|
# Native port of fusion_plating_bridge_mrp's
|
|
# _generate_workorders_from_recipe. Walks the recipe tree, creates
|
|
# one fp.job.step per 'operation' node, formats child 'step' nodes
|
|
# as step instructions on chatter, respects opt-in/out overrides
|
|
# from fp.job.node.override.
|
|
#
|
|
# Adaptations from the original:
|
|
# - Creates fp.job.step (not mrp.workorder)
|
|
# - Maps fusion.plating.work.center → fp.work.centre via code
|
|
# fallback (no forward link exists yet)
|
|
# - Uses native field names (job_id, work_centre_id, etc.)
|
|
# - Drops work_role_id (not on fp.job.step yet — Task 2.6+)
|
|
# - Drops _fp_autofill_default_equipment (not yet on step)
|
|
# ------------------------------------------------------------------
|
|
def _generate_steps_from_recipe(self):
|
|
"""Generate fp.job.step records from the assigned recipe.
|
|
|
|
Walks the recipe tree, creates one step per 'operation' node,
|
|
and formats child 'step' nodes as step instructions on the
|
|
chatter. Respects opt-in/out overrides from override_ids.
|
|
"""
|
|
Step = self.env['fp.job.step']
|
|
Node = self.env['fusion.plating.process.node']
|
|
for job in self:
|
|
if not job.recipe_id:
|
|
continue # No recipe assigned
|
|
if job.step_ids:
|
|
continue # Steps already exist — don't duplicate
|
|
|
|
# Build lookup of overrides keyed by node ID
|
|
override_map = {ov.node_id.id: ov.included for ov in job.override_ids}
|
|
|
|
# Start-at-node: if set, the allowed set is the union of:
|
|
# 1. start_node and all its descendants
|
|
# 2. each ancestor of start_node
|
|
# 3. at each ancestor level, any LATER-sequence sibling and
|
|
# all of its descendants
|
|
start_node = job.start_at_node_id
|
|
allowed_ids = None # None = include everything
|
|
if start_node:
|
|
descendants = Node.search([('id', 'child_of', start_node.id)])
|
|
allowed_ids = set(descendants.ids)
|
|
cur = start_node
|
|
while cur.parent_id:
|
|
parent = cur.parent_id
|
|
allowed_ids.add(parent.id)
|
|
later_sibs = parent.child_ids.filtered(
|
|
lambda n: n.sequence > cur.sequence
|
|
)
|
|
for sib in later_sibs:
|
|
sib_descendants = Node.search([
|
|
('id', 'child_of', sib.id),
|
|
])
|
|
allowed_ids |= set(sib_descendants.ids)
|
|
cur = parent
|
|
|
|
step_vals_list = []
|
|
wo_steps = {} # {sequence: instruction text}
|
|
seq_counter = [10]
|
|
|
|
def _is_node_included(node):
|
|
"""Determine if a node should be included based on
|
|
opt-in/out logic, per-job overrides, and start-at-node
|
|
filter.
|
|
"""
|
|
nid = node.id
|
|
if allowed_ids is not None and nid not in allowed_ids:
|
|
return False
|
|
opt = node.opt_in_out or 'disabled'
|
|
if opt == 'disabled':
|
|
return True
|
|
if nid in override_map:
|
|
return override_map[nid]
|
|
if opt == 'opt_in':
|
|
return False # Default excluded
|
|
return True # opt_out → default included
|
|
|
|
def _resolve_work_centre(legacy_wc):
|
|
"""Map fusion.plating.work.center → fp.work.centre.
|
|
|
|
The legacy work-centre model does not (yet) have a forward
|
|
link to the new fp.work.centre. Try a forward link
|
|
(x_fc_fp_work_centre_id) if some bridge module added one;
|
|
otherwise fall back to a code lookup.
|
|
"""
|
|
if not legacy_wc:
|
|
return self.env['fp.work.centre']
|
|
# Forward link, if any
|
|
if (
|
|
'x_fc_fp_work_centre_id' in legacy_wc._fields
|
|
and legacy_wc.x_fc_fp_work_centre_id
|
|
):
|
|
return legacy_wc.x_fc_fp_work_centre_id
|
|
# Code fallback (legacy code is unique-per-facility,
|
|
# native code is globally unique — first match wins)
|
|
if legacy_wc.code:
|
|
found = self.env['fp.work.centre'].search(
|
|
[('code', '=', legacy_wc.code)], limit=1,
|
|
)
|
|
if found:
|
|
return found
|
|
return self.env['fp.work.centre']
|
|
|
|
def walk_node(node):
|
|
if not _is_node_included(node):
|
|
return
|
|
|
|
if node.node_type == 'operation':
|
|
work_centre = _resolve_work_centre(node.work_center_id)
|
|
if not work_centre:
|
|
_logger.warning(
|
|
'Job %s: operation "%s" has no mapped fp.work.centre — '
|
|
'creating step without work centre.',
|
|
job.name, node.name,
|
|
)
|
|
|
|
# Collect step instructions from child 'step' nodes
|
|
instructions = []
|
|
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
|
|
instructions.append(line)
|
|
step_num += 1
|
|
|
|
vals = {
|
|
'job_id': job.id,
|
|
'name': node.name,
|
|
'work_centre_id': work_centre.id if work_centre else False,
|
|
'duration_expected': node.estimated_duration or 0.0,
|
|
'sequence': seq_counter[0],
|
|
'recipe_node_id': node.id,
|
|
}
|
|
if node.estimated_duration:
|
|
vals['dwell_time_minutes'] = node.estimated_duration
|
|
|
|
# Pull thickness target from the coating config when
|
|
# this is a plating step (matched by node name keyword).
|
|
coating = job.coating_config_id
|
|
name_l = (node.name or '').lower()
|
|
is_plating_node = (
|
|
'plat' in name_l or 'nickel' in name_l
|
|
or 'chrome' in name_l or 'anodiz' in name_l
|
|
)
|
|
if coating and is_plating_node:
|
|
if (
|
|
'thickness_max' in coating._fields
|
|
and coating.thickness_max
|
|
):
|
|
vals['thickness_target'] = coating.thickness_max
|
|
if (
|
|
'thickness_uom' in coating._fields
|
|
and coating.thickness_uom
|
|
):
|
|
vals['thickness_uom'] = coating.thickness_uom
|
|
|
|
step_vals_list.append(vals)
|
|
if instructions:
|
|
wo_steps[seq_counter[0]] = '\n'.join(instructions)
|
|
seq_counter[0] += 10
|
|
|
|
elif node.node_type in ('recipe', 'sub_process'):
|
|
for child in node.child_ids.sorted('sequence'):
|
|
walk_node(child)
|
|
# 'step' nodes at top level are handled by their parent operation
|
|
|
|
# Walk from recipe root
|
|
walk_node(job.recipe_id)
|
|
|
|
# Bulk create
|
|
if step_vals_list:
|
|
created = Step.create(step_vals_list)
|
|
for step in created:
|
|
instr_text = wo_steps.get(step.sequence)
|
|
if instr_text:
|
|
step.message_post(
|
|
body=Markup(
|
|
'<b>Recipe steps:</b><br/><pre>%s</pre>'
|
|
) % instr_text,
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
job.message_post(
|
|
body=('%d steps generated from recipe "%s".') % (
|
|
len(step_vals_list), job.recipe_id.name,
|
|
),
|
|
)
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# UI — Process Tree client action (Phase 6)
|
|
# ------------------------------------------------------------------
|
|
def action_open_process_tree(self):
|
|
"""Open the OWL process-tree visualization for this job.
|
|
|
|
Launches the fp_job_process_tree client action with job_id in
|
|
context. The component fetches /fp/jobs/process_tree and renders
|
|
the recipe -> sub_process -> operation hierarchy as cards with
|
|
per-step state badges.
|
|
"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'fp_job_process_tree',
|
|
'context': {'job_id': self.id},
|
|
'name': 'Process Tree — %s' % (self.name or ''),
|
|
'target': 'current',
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle hooks (Tasks 2.6, 2.7, 2.8)
|
|
#
|
|
# On confirm: create the portal-job mirror record and (when the
|
|
# customer requires QC) a fusion.plating.quality.check.
|
|
# On done: create a draft fusion.plating.delivery and best-effort
|
|
# trigger fp.certificate auto-generation.
|
|
#
|
|
# The QC and certificate models live in modules this module does NOT
|
|
# depend on by design (bridge_mrp). We runtime-detect those models so
|
|
# the hooks degrade gracefully when those modules are absent.
|
|
# ------------------------------------------------------------------
|
|
def action_confirm(self):
|
|
result = super().action_confirm()
|
|
# During migration, lifecycle side-effects are skipped — the
|
|
# migration script directly rebinds existing portal/QC/inspection
|
|
# records via x_fc_job_id. See scripts/migrate_to_fp_jobs.py.
|
|
if self.env.context.get('fp_jobs_migration'):
|
|
return result
|
|
for job in self:
|
|
job._fp_create_portal_job()
|
|
job._fp_create_qc_check_if_needed()
|
|
job._fp_create_racking_inspection()
|
|
job._fp_fire_notification('job_confirmed')
|
|
return result
|
|
|
|
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.
|
|
"""
|
|
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,
|
|
)
|
|
return
|
|
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(
|
|
"Job %s: failed to auto-create racking inspection: %s",
|
|
self.name, e,
|
|
)
|
|
|
|
def _fp_create_portal_job(self):
|
|
"""Create the fusion.plating.portal.job mirror record."""
|
|
self.ensure_one()
|
|
if self.portal_job_id:
|
|
return # already exists — idempotent
|
|
Portal = self.env['fusion.plating.portal.job'].sudo()
|
|
portal = Portal.create({
|
|
'name': self.name,
|
|
'partner_id': self.partner_id.id,
|
|
'state': 'in_progress',
|
|
'x_fc_job_id': self.id,
|
|
})
|
|
self.portal_job_id = portal.id
|
|
|
|
def _fp_create_qc_check_if_needed(self):
|
|
"""If customer has x_fc_requires_qc=True, create a QC check.
|
|
|
|
The fusion.plating.quality.check model lives in
|
|
fusion_plating_bridge_mrp; we runtime-detect it to avoid a
|
|
depends-on-bridge_mrp cycle. If the model isn't registered, log
|
|
a warning and skip — bridge_mrp can be installed later without
|
|
breaking this flow.
|
|
"""
|
|
self.ensure_one()
|
|
partner = self.partner_id
|
|
wants_qc = (
|
|
'x_fc_requires_qc' in partner._fields
|
|
and partner.x_fc_requires_qc
|
|
)
|
|
if not wants_qc:
|
|
return
|
|
if 'fusion.plating.quality.check' not in self.env:
|
|
_logger.warning(
|
|
"Job %s: customer wants QC but fusion.plating.quality.check "
|
|
"model not registered (bridge_mrp deferral).", self.name,
|
|
)
|
|
return
|
|
QC = self.env['fusion.plating.quality.check'].sudo()
|
|
# Try to create with the most likely required fields. If the
|
|
# model has a different schema than expected, this may need
|
|
# adjustment when bridge_mrp's QC model lands here.
|
|
try:
|
|
qc_vals = {
|
|
'partner_id': partner.id,
|
|
'state': 'pending',
|
|
}
|
|
# Try the new field name first; fallback to mrp-bound.
|
|
if 'job_id' in QC._fields:
|
|
qc_vals['job_id'] = self.id
|
|
elif 'production_id' in QC._fields:
|
|
# bridge_mrp's QC binds to production. We can't fill that
|
|
# from here — leave it null and let a manual link happen.
|
|
pass
|
|
QC.create(qc_vals)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Job %s: failed to create QC check: %s", self.name, e,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# button_mark_done — Task 2.8
|
|
# ------------------------------------------------------------------
|
|
def button_mark_done(self):
|
|
"""Transition the job to 'done' and trigger downstream side effects.
|
|
|
|
- Sets state='done', date_finished=now
|
|
- Auto-creates a draft fusion.plating.delivery
|
|
- Triggers certificate auto-generation (best-effort)
|
|
"""
|
|
# During migration, side-effects are skipped — see action_confirm.
|
|
skip_side_effects = self.env.context.get('fp_jobs_migration')
|
|
for job in self:
|
|
if job.state == 'done':
|
|
continue
|
|
if job.state == 'cancelled':
|
|
raise UserError(
|
|
"Job %s is cancelled — cannot mark done." % job.name
|
|
)
|
|
job.state = 'done'
|
|
job.date_finished = fields.Datetime.now()
|
|
if not skip_side_effects:
|
|
job._fp_create_delivery()
|
|
job._fp_create_certificates()
|
|
job._fp_fire_notification('job_complete')
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Notifications dispatch (Phase 4)
|
|
#
|
|
# Fires fp.notification.template records whose trigger_event matches
|
|
# the given event name. Best-effort: silently skips if the
|
|
# fusion_plating_notifications module is not installed (model not
|
|
# registered) and logs (without raising) on any send failure so the
|
|
# job lifecycle is never blocked by an email problem.
|
|
# ------------------------------------------------------------------
|
|
def _fp_fire_notification(self, event):
|
|
"""Best-effort notification dispatch for fp.job lifecycle events.
|
|
|
|
Looks up fp.notification.template records with the matching
|
|
trigger_event and dispatches via the central _dispatch helper
|
|
provided by fusion_plating_notifications. Silently no-ops when
|
|
that module isn't installed.
|
|
"""
|
|
self.ensure_one()
|
|
if 'fp.notification.template' not in self.env:
|
|
return
|
|
Template = self.env['fp.notification.template'].sudo()
|
|
try:
|
|
# The notifications module exposes a model-level _dispatch
|
|
# helper that handles template lookup, recipient resolution
|
|
# (Sub 6 contact routing), attachment rendering, and audit
|
|
# logging in one go. Pass partner explicitly since fp.job's
|
|
# partner_id is the customer.
|
|
Template._dispatch(event, self, partner=self.partner_id)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Job %s: notification %s dispatch failed: %s",
|
|
self.name, event, e,
|
|
)
|
|
|
|
def _fp_create_delivery(self):
|
|
"""Create a draft fusion.plating.delivery linked to this job."""
|
|
self.ensure_one()
|
|
if self.delivery_id:
|
|
return
|
|
Delivery = self.env['fusion.plating.delivery'].sudo()
|
|
# Verify the model has a job link field. The current delivery
|
|
# model uses `job_ref` (Char) as a soft reference; some forks
|
|
# may add `x_fc_job_id` (Many2one).
|
|
if 'x_fc_job_id' in Delivery._fields:
|
|
ref_field = 'x_fc_job_id'
|
|
ref_value = self.id
|
|
elif 'job_ref' in Delivery._fields:
|
|
ref_field = 'job_ref'
|
|
ref_value = self.name
|
|
else:
|
|
_logger.warning(
|
|
"Job %s: fusion.plating.delivery has no job link field; "
|
|
"delivery created without job back-reference.", self.name,
|
|
)
|
|
ref_field = None
|
|
ref_value = None
|
|
try:
|
|
vals = {
|
|
'partner_id': self.partner_id.id,
|
|
}
|
|
if ref_field:
|
|
vals[ref_field] = ref_value
|
|
delivery = Delivery.create(vals)
|
|
self.delivery_id = delivery.id
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Job %s: failed to auto-create delivery: %s", self.name, e,
|
|
)
|
|
|
|
def _fp_create_certificates(self):
|
|
"""Trigger cert auto-create on job done.
|
|
|
|
Best-effort: if fp.certificate has the right fields, create a
|
|
draft CoC. Otherwise log + skip.
|
|
"""
|
|
self.ensure_one()
|
|
if 'fp.certificate' not in self.env:
|
|
return
|
|
Cert = self.env['fp.certificate'].sudo()
|
|
try:
|
|
vals = {
|
|
'partner_id': self.partner_id.id,
|
|
}
|
|
if 'certificate_type' in Cert._fields:
|
|
vals['certificate_type'] = 'coc'
|
|
if 'state' in Cert._fields:
|
|
vals['state'] = 'draft'
|
|
# Add job link if Cert has the field
|
|
if 'x_fc_job_id' in Cert._fields:
|
|
vals['x_fc_job_id'] = self.id
|
|
elif 'job_id' in Cert._fields:
|
|
vals['job_id'] = self.id
|
|
elif 'sale_order_id' in Cert._fields and self.sale_order_id:
|
|
vals['sale_order_id'] = self.sale_order_id.id
|
|
Cert.create(vals)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Job %s: failed to auto-create cert: %s", self.name, e,
|
|
)
|
|
|
|
|
|
class FpJobStep(models.Model):
|
|
"""Phase 7 — adds the migration idempotency key on fp.job.step.
|
|
|
|
Populated by scripts/migrate_to_fp_jobs.py to mark a step as the
|
|
mirror of a specific mrp.workorder. Used to skip already-migrated
|
|
WOs on subsequent runs.
|
|
"""
|
|
_inherit = 'fp.job.step'
|
|
|
|
legacy_mrp_workorder_id = fields.Integer(
|
|
string='Legacy MRP Work Order ID',
|
|
index=True,
|
|
help='Database id of the source mrp.workorder this step was '
|
|
'migrated from. Used by the migration script for '
|
|
'idempotency. Cleared post-cutover.',
|
|
)
|