changes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- ============================================================
|
||||
|
||||
Reference in New Issue
Block a user