This commit is contained in:
gsinghpal
2026-04-20 01:16:12 -04:00
parent 8217bb0ff6
commit 54e56ed0e6
39 changed files with 5600 additions and 1131 deletions

View File

@@ -23,8 +23,6 @@
<field name="code">model._fp_cron_auto_finish_completed_wos()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
</data>

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
@@ -191,6 +193,74 @@ class MrpWorkorder(models.Model):
help='Wall-clock time the timer was closed for the last time.',
)
# ------------------------------------------------------------------
# Recipe-node link + behaviour flags propagated from the recipe.
# _generate_workorders_from_recipe stores the link at WO creation;
# the related fields here let the start/finish gates and the
# auto-complete cron resolve flags in O(1) without joining by name.
# ------------------------------------------------------------------
x_fc_recipe_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Node',
readonly=True, copy=False, index=True,
help='The operation node in the recipe template that produced '
'this work order. Drives auto-complete, sign-off and '
'manual/automated behaviour.',
)
x_fc_requires_signoff = fields.Boolean(
related='x_fc_recipe_node_id.requires_signoff',
store=True, readonly=True,
help='Recipe says this is a quality hold point — finish is '
'blocked until an operator records a sign-off.',
)
x_fc_is_manual = fields.Boolean(
related='x_fc_recipe_node_id.is_manual',
store=True, readonly=True,
help='If false, this is an automated step — the worker '
'assignment gate is skipped on Start.',
)
x_fc_auto_complete = fields.Boolean(
related='x_fc_recipe_node_id.auto_complete',
store=True, readonly=True,
help='If true, the cron auto-finishes the WO once it has been '
'in Progress for at least its expected duration.',
)
x_fc_signoff_user_id = fields.Many2one(
'res.users', string='Signed Off By',
readonly=True, copy=False,
help='Operator who signed off on the quality hold point. '
'Required to finish a WO whose recipe sets requires_signoff.',
)
x_fc_signoff_date = fields.Datetime(
string='Signed Off At',
readonly=True, copy=False,
)
# Contract-review approver list lifted from the recipe root via the
# node link. Computed on the fly — we tried a `related=` field but
# Odoo's M2M-through-M2O-through-M2O related chain didn't populate
# reliably in tests. A small compute is more predictable.
x_fc_contract_review_user_ids = fields.Many2many(
'res.users',
relation='fp_wo_contract_review_user_rel',
column1='wo_id',
column2='user_id',
string='Contract Review Approvers',
compute='_compute_contract_review_approvers',
store=False,
)
@api.depends('x_fc_recipe_node_id')
def _compute_contract_review_approvers(self):
for wo in self:
recipe = (
wo.x_fc_recipe_node_id.recipe_root_id
if wo.x_fc_recipe_node_id else False
)
wo.x_fc_contract_review_user_ids = (
recipe.contract_review_user_ids
if recipe else self.env['res.users']
)
# ------------------------------------------------------------------
# Workflow step tracking
# ------------------------------------------------------------------
@@ -362,13 +432,22 @@ class MrpWorkorder(models.Model):
# Process tree action (opens OWL client action)
# ------------------------------------------------------------------
def action_view_process_tree(self):
"""Open the OWL process tree view for this MO's routing."""
"""Open the OWL process tree view for this MO's routing.
Passes `back_workorder_id` so the tree's "Back" button returns to
the WO the user came from instead of always jumping to Plant
Overview.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_process_tree',
'name': f'Process Tree — {self.production_id.name}',
'context': {'production_id': self.production_id.id},
'context': {
'production_id': self.production_id.id,
'back_workorder_id': self.id,
'back_workorder_name': self.display_name or self.name,
},
}
# ------------------------------------------------------------------
@@ -629,7 +708,7 @@ class MrpWorkorder(models.Model):
@api.depends('x_fc_assigned_user_id', 'x_fc_bath_id', 'x_fc_tank_id',
'x_fc_oven_id', 'x_fc_rack_id', 'x_fc_masking_material',
'x_fc_wo_kind')
'x_fc_wo_kind', 'x_fc_is_manual')
def _compute_is_release_ready(self):
"""A WO is release-ready when the manager has set EVERY field
button_start would block on. Used by the Manager Desk to keep
@@ -638,7 +717,9 @@ class MrpWorkorder(models.Model):
"""
for wo in self:
missing = []
if not wo.x_fc_assigned_user_id:
# Skip the operator requirement for automated steps so the
# Manager Desk doesn't park them in Setup Pending forever.
if not wo.x_fc_assigned_user_id and wo.x_fc_is_manual:
missing.append('Operator')
kind = wo.x_fc_wo_kind
if kind == 'wet':
@@ -771,7 +852,11 @@ class MrpWorkorder(models.Model):
from odoo.exceptions import UserError
for wo in self:
missing = []
if not wo.x_fc_assigned_user_id:
# Automated steps (recipe.is_manual=False) don't need a
# human operator — the equipment runs unattended (timed
# immersion, automated rinse, etc.). The kind-specific
# equipment checks below still apply.
if not wo.x_fc_assigned_user_id and wo.x_fc_is_manual:
missing.append(_('Assigned Operator'))
kind = wo._fp_classify_kind()
if kind == 'wet':
@@ -847,17 +932,62 @@ class MrpWorkorder(models.Model):
) % (employee.name, process_type.name))
def _fp_check_required_fields_before_finish(self):
"""Block button_finish on bake WOs without the actual data
Nadcap audits demand: setpoint temp, actual duration, and a
chart-recorder reference on the oven (so the printed chart
for this run can be retrieved).
"""Block button_finish on:
- bake WOs without setpoint temp / actual duration / chart-recorder
ref (Nadcap requirement);
- any WO whose recipe node is `requires_signoff` and has no
sign-off recorded yet (quality hold point).
Run-time data (temp + duration) belongs at FINISH because
you don't know it until the bake is done. Chart-recorder ref
is on the oven config — checked here as a defensive backstop.
you don't know it until the bake is done.
"""
from odoo.exceptions import UserError
for wo in self:
# ---- Contract Review approver gate ---------------------------
# Only authorised users (per the recipe's
# contract_review_user_ids) can finish the Contract Review WO.
# Detected by the recipe-node name match — robust enough since
# this is a well-known operation in every recipe.
node = wo.x_fc_recipe_node_id
if (
node and (node.name or '').strip().lower() == 'contract review'
and wo.x_fc_contract_review_user_ids
and self.env.user not in wo.x_fc_contract_review_user_ids
and not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
):
allowed = ', '.join(
wo.x_fc_contract_review_user_ids.mapped('name')
) or '(none configured)'
raise UserError(_(
'Cannot finish Contract Review for "%(wo)s"'
'this approval is restricted to: %(allowed)s.\n\n'
'You (%(user)s) are not on the approver list for '
'recipe "%(recipe)s". Ask one of the approvers to '
'sign off, or have a Plating Manager finish it on '
'their behalf.'
) % {
'wo': wo.display_name or wo.name,
'allowed': allowed,
'user': self.env.user.name,
'recipe': (node.recipe_root_id.name or ''),
})
# ---- Quality hold point: requires sign-off -------------------
if wo.x_fc_requires_signoff and not wo.x_fc_signoff_user_id:
raise UserError(_(
'Cannot finish work order "%(wo)s" — recipe step '
'"%(node)s" is a quality hold point and requires '
'an operator sign-off first.\n\n'
'On the WO form: tap "Sign Off" before clicking '
'Finish. The sign-off captures who certified the '
'work and is recorded in the audit trail.'
) % {
'wo': wo.display_name or wo.name,
'node': (wo.x_fc_recipe_node_id.name or wo.name),
})
if wo._fp_classify_kind() != 'bake':
continue
missing = []
@@ -981,3 +1111,83 @@ class MrpWorkorder(models.Model):
'within %s hours of plate exit.'
) % (coating.bake_window_hours or 4.0)
)
# ------------------------------------------------------------------
# Sign-off (recipe quality hold point)
# ------------------------------------------------------------------
def action_signoff(self):
"""Capture the current user as the sign-off operator + timestamp.
The button only makes sense for WOs whose recipe step is marked
`requires_signoff`. The view hides the button otherwise.
"""
from odoo.exceptions import UserError
for wo in self:
if not wo.x_fc_requires_signoff:
raise UserError(_(
'Work order "%s" is not a quality hold point — '
'no sign-off required.'
) % (wo.display_name or wo.name))
wo.write({
'x_fc_signoff_user_id': self.env.user.id,
'x_fc_signoff_date': fields.Datetime.now(),
})
wo.message_post(
body=Markup(_(
'Quality hold point signed off by <b>%s</b>.'
)) % self.env.user.name,
)
return True
# ------------------------------------------------------------------
# Auto-complete cron
# ------------------------------------------------------------------
@api.model
def _fp_cron_auto_finish_completed_wos(self):
"""Cron entry point — auto-finish WOs whose recipe step is marked
`auto_complete` once they've been in Progress for at least their
expected duration.
Used for fully-automated steps (timed immersion, automated rinse)
where the equipment runs unattended. Manual steps are unaffected.
Skips WOs that still have a sign-off requirement: those must be
finished by the operator after they've certified the work.
"""
candidates = self.search([
('state', '=', 'progress'),
('x_fc_auto_complete', '=', True),
('x_fc_started_at', '!=', False),
('duration_expected', '>', 0),
])
if not candidates:
return 0
now = fields.Datetime.now()
finished = 0
for wo in candidates:
if wo.x_fc_requires_signoff and not wo.x_fc_signoff_user_id:
# Quality hold trumps auto-complete — wait for the
# operator's sign-off before closing.
continue
elapsed_min = (now - wo.x_fc_started_at).total_seconds() / 60.0
if elapsed_min < (wo.duration_expected or 0):
continue
try:
wo.with_user(
wo.x_fc_assigned_user_id or self.env.user
).button_finish()
wo.message_post(
body=Markup(_(
'Auto-finished by recipe (auto_complete) after '
'%.1f min — expected %.1f min.'
)) % (elapsed_min, wo.duration_expected),
subtype_xmlid='mail.mt_note',
)
finished += 1
except Exception as exc: # noqa: BLE001
import logging
logging.getLogger(__name__).warning(
'Auto-complete failed for WO %s (%s): %s',
wo.id, wo.display_name, exc,
)
return finished

View File

@@ -102,6 +102,25 @@
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
<field name="x_fc_requires_bath" invisible="1"/>
<field name="x_fc_requires_oven" invisible="1"/>
<field name="x_fc_recipe_node_id" invisible="1"/>
<field name="x_fc_requires_signoff" invisible="1"/>
<field name="x_fc_is_manual" invisible="1"/>
<field name="x_fc_auto_complete" invisible="1"/>
<field name="x_fc_signoff_user_id" readonly="1"
invisible="not x_fc_requires_signoff"/>
<field name="x_fc_signoff_date" readonly="1"
invisible="not x_fc_requires_signoff"/>
</xpath>
<!-- ============================================================
SIGN OFF BUTTON — only visible when the recipe step
requires a sign-off and the WO is in progress.
============================================================ -->
<xpath expr="//header" position="inside">
<button name="action_signoff" type="object"
string="Sign Off" class="oe_highlight"
icon="fa-check-square-o"
invisible="not x_fc_requires_signoff or x_fc_signoff_user_id or state in ('done', 'cancel')"/>
</xpath>
<!-- ============================================================