Three connected operator-workflow features for entech.
A. fp.job smart buttons — count fields and action methods for sale
order, steps, deliveries, invoices, payments, quality holds,
certificates, time logs, and portal job. Each is an oe_stat_button
that drills into the matching records, mirroring the sale.order
pattern. Cross-module models are runtime-detected so the form
stays clean when bridge modules are uninstalled.
B. Reusable QR scanner OWL component (`<QrScanner/>`) wired into the
Manager Desk, Tablet Station, Plant Overview, and Process Tree
headers. Click → modal with rear-camera stream (getUserMedia) +
BarcodeDetector live decode → opens the matching fp.job form via
the action service. Falls back to a manual URL paste box on
browsers without BarcodeDetector. Works on iOS 17+ Safari and
Android Chrome. Width uses `min(420px, 92vw)` wrapped in #{} so
dart-sass passes it through verbatim instead of trying to compute
incompatible units at compile time.
C. /fp/tank/<id> public-but-auth-required tank status page for NFC
taps. Renders the tank's current step (in-progress / paused),
queued ready steps, and most recent bath chemistry log (lines
table) on a mobile-first page. URL-based so it works on iOS Safari
without the Web NFC API — the operator taps the NFC tag, the URL
opens in the default browser, the page auto-renders. New
web.assets_frontend bundle entry pulls in the design tokens +
tank_status.scss.
Manifest version bumps: jobs 19.0.5.0.0, shopfloor 19.0.16.0.0.
Tests: 44 pass (3 new smart-button assertions added).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
769 lines
31 KiB
Python
769 lines
31 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 api, 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.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Smart-button counts (Feature A — operator workflow)
|
|
#
|
|
# Compute counts for each downstream model so the form view can
|
|
# render an oe_stat_button row similar to sale.order. Cross-module
|
|
# models are runtime-detected so this still works when one of the
|
|
# bridge modules is uninstalled.
|
|
# ------------------------------------------------------------------
|
|
sale_order_count = fields.Integer(compute='_compute_smart_counts')
|
|
delivery_count = fields.Integer(compute='_compute_smart_counts')
|
|
invoice_count = fields.Integer(compute='_compute_smart_counts')
|
|
payment_count = fields.Integer(compute='_compute_smart_counts')
|
|
quality_hold_count = fields.Integer(compute='_compute_smart_counts')
|
|
certificate_count = fields.Integer(compute='_compute_smart_counts')
|
|
timelog_count = fields.Integer(compute='_compute_smart_counts')
|
|
portal_job_count = fields.Integer(compute='_compute_smart_counts')
|
|
|
|
@api.depends(
|
|
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
|
|
'step_ids.time_log_ids', 'origin', 'partner_id',
|
|
)
|
|
def _compute_smart_counts(self):
|
|
AccountMove = self.env.get('account.move')
|
|
AccountPayment = self.env.get('account.payment')
|
|
QualityHold = self.env.get('fusion.plating.quality.hold')
|
|
Certificate = self.env.get('fp.certificate')
|
|
for job in self:
|
|
job.sale_order_count = 1 if job.sale_order_id else 0
|
|
job.delivery_count = 1 if job.delivery_id else 0
|
|
job.portal_job_count = 1 if job.portal_job_id else 0
|
|
|
|
# Invoices via origin (the SO name)
|
|
if AccountMove is not None and job.origin:
|
|
job.invoice_count = AccountMove.search_count([
|
|
('invoice_origin', '=', job.origin),
|
|
('move_type', 'in', ('out_invoice', 'out_refund')),
|
|
])
|
|
else:
|
|
job.invoice_count = 0
|
|
|
|
# Payments — find invoices for this SO, then payments
|
|
# reconciled against them.
|
|
if (AccountMove is not None and AccountPayment is not None
|
|
and job.origin):
|
|
inv_ids = AccountMove.search([
|
|
('invoice_origin', '=', job.origin),
|
|
('move_type', 'in', ('out_invoice', 'out_refund')),
|
|
]).ids
|
|
if inv_ids:
|
|
job.payment_count = AccountPayment.search_count([
|
|
('reconciled_invoice_ids', 'in', inv_ids),
|
|
])
|
|
else:
|
|
job.payment_count = 0
|
|
else:
|
|
job.payment_count = 0
|
|
|
|
if QualityHold is not None:
|
|
job.quality_hold_count = QualityHold.search_count([
|
|
('x_fc_job_id', '=', job.id),
|
|
])
|
|
else:
|
|
job.quality_hold_count = 0
|
|
|
|
if Certificate is not None:
|
|
job.certificate_count = Certificate.search_count([
|
|
('x_fc_job_id', '=', job.id),
|
|
])
|
|
else:
|
|
job.certificate_count = 0
|
|
|
|
job.timelog_count = sum(
|
|
len(s.time_log_ids) for s in job.step_ids
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Smart-button actions
|
|
# ------------------------------------------------------------------
|
|
def action_view_sale_order(self):
|
|
self.ensure_one()
|
|
if not self.sale_order_id:
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'sale.order',
|
|
'res_id': self.sale_order_id.id,
|
|
'view_mode': 'form',
|
|
'name': self.sale_order_id.name,
|
|
}
|
|
|
|
def action_view_steps(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fp.job.step',
|
|
'view_mode': 'list,form',
|
|
'domain': [('job_id', '=', self.id)],
|
|
'name': 'Steps — %s' % self.name,
|
|
'context': {'default_job_id': self.id},
|
|
}
|
|
|
|
def action_view_deliveries(self):
|
|
self.ensure_one()
|
|
if not self.delivery_id:
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fusion.plating.delivery',
|
|
'res_id': self.delivery_id.id,
|
|
'view_mode': 'form',
|
|
'name': self.delivery_id.name,
|
|
}
|
|
|
|
def action_view_invoices(self):
|
|
self.ensure_one()
|
|
if not self.origin:
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.move',
|
|
'view_mode': 'list,form',
|
|
'domain': [
|
|
('invoice_origin', '=', self.origin),
|
|
('move_type', 'in', ('out_invoice', 'out_refund')),
|
|
],
|
|
'name': 'Invoices — %s' % self.name,
|
|
}
|
|
|
|
def action_view_payments(self):
|
|
self.ensure_one()
|
|
if not self.origin:
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
AccountMove = self.env.get('account.move')
|
|
if AccountMove is None:
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
inv_ids = AccountMove.search([
|
|
('invoice_origin', '=', self.origin),
|
|
('move_type', 'in', ('out_invoice', 'out_refund')),
|
|
]).ids
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.payment',
|
|
'view_mode': 'list,form',
|
|
'domain': (
|
|
[('reconciled_invoice_ids', 'in', inv_ids)]
|
|
if inv_ids else [('id', '=', 0)]
|
|
),
|
|
'name': 'Payments — %s' % self.name,
|
|
}
|
|
|
|
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': [('x_fc_job_id', '=', self.id)],
|
|
'name': 'Quality Holds — %s' % self.name,
|
|
'context': {'default_x_fc_job_id': self.id},
|
|
}
|
|
|
|
def action_view_certificates(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fp.certificate',
|
|
'view_mode': 'list,form',
|
|
'domain': [('x_fc_job_id', '=', self.id)],
|
|
'name': 'Certificates — %s' % self.name,
|
|
'context': {'default_x_fc_job_id': self.id},
|
|
}
|
|
|
|
def action_view_timelogs(self):
|
|
self.ensure_one()
|
|
step_ids = self.step_ids.ids
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fp.job.step.timelog',
|
|
'view_mode': 'list,form',
|
|
'domain': (
|
|
[('step_id', 'in', step_ids)]
|
|
if step_ids else [('id', '=', 0)]
|
|
),
|
|
'name': 'Time Logs — %s' % self.name,
|
|
}
|
|
|
|
def action_view_portal_job(self):
|
|
self.ensure_one()
|
|
if not self.portal_job_id:
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fusion.plating.portal.job',
|
|
'res_id': self.portal_job_id.id,
|
|
'view_mode': 'form',
|
|
'name': self.portal_job_id.name,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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
|
|
):
|
|
# fp.coating.config uses long-form uom names
|
|
# (mils / microns / inches); fp.job.step uses
|
|
# short codes (mil / um / inch). Map between
|
|
# them. Unknown values fall through to the
|
|
# step's default ('um').
|
|
_UOM_MAP = {
|
|
'mils': 'mil',
|
|
'mil': 'mil',
|
|
'microns': 'um',
|
|
'micron': 'um',
|
|
'um': 'um',
|
|
'inches': 'inch',
|
|
'inch': 'inch',
|
|
'in': 'inch',
|
|
}
|
|
mapped = _UOM_MAP.get(coating.thickness_uom)
|
|
if mapped:
|
|
vals['thickness_uom'] = mapped
|
|
|
|
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_process_tree client action (defined in
|
|
fusion_plating_shopfloor) with job_id in context. The component
|
|
fetches /fp/shopfloor/process_tree and renders the recipe ->
|
|
sub_process -> operation hierarchy as cards with per-step state
|
|
badges.
|
|
|
|
Consolidated 2026-04-24: this points at the canonical shopfloor
|
|
client action; the parallel fp_job_process_tree was removed.
|
|
"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'fp_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.',
|
|
)
|