783 lines
34 KiB
Python
783 lines
34 KiB
Python
# -*- 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.
|
||
|
||
import logging
|
||
import re
|
||
|
||
from markupsafe import Markup
|
||
|
||
from odoo import _, api, fields, models
|
||
from odoo.exceptions import UserError
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class FpJobStep(models.Model):
|
||
_inherit = 'fp.job.step'
|
||
|
||
def button_start(self):
|
||
"""Override — soft gate when parts haven't been received yet,
|
||
plus hard predecessor gate for steps flagged
|
||
requires_predecessor_done by the recipe author.
|
||
|
||
Receiving check is soft (logs to chatter) — manager wants the
|
||
shop to start prep regardless when parts are in-transit late.
|
||
|
||
Predecessor check IS hard-blocking — if the recipe author
|
||
marked this step as serial-required, every earlier-sequence
|
||
step must be terminal (done / skipped / cancelled) before
|
||
Start fires. Manager bypass via fp_skip_predecessor_check=True.
|
||
"""
|
||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||
for step in self:
|
||
if not step.requires_predecessor_done or skip_pred:
|
||
continue
|
||
blocking = step.job_id.step_ids.filtered(
|
||
lambda s: s.sequence < step.sequence and s.state not in (
|
||
'done', 'skipped', 'cancelled',
|
||
)
|
||
)
|
||
if blocking:
|
||
raise UserError(_(
|
||
"Step '%s' requires predecessors done first. "
|
||
"Blocking earlier step(s):\n %s\n\nFinish or skip "
|
||
"those before starting this one (manager can "
|
||
"override via context fp_skip_predecessor_check=True)."
|
||
) % (
|
||
step.name,
|
||
'\n '.join(
|
||
f'#{s.sequence} {s.name} ({s.state})'
|
||
for s in blocking[:5]
|
||
),
|
||
))
|
||
result = super().button_start()
|
||
for step in self:
|
||
so = step.job_id.sale_order_id
|
||
if not so:
|
||
continue
|
||
recv = so.x_fc_receiving_status if (
|
||
'x_fc_receiving_status' in so._fields
|
||
) else None
|
||
if recv in (False, None, 'not_received'):
|
||
step.job_id.message_post(body=_(
|
||
'Step "%(step)s" started before parts were received '
|
||
'(SO %(so)s — receiving status: %(status)s). '
|
||
'Confirm the parts are physically on the floor before '
|
||
'continuing.'
|
||
) % {
|
||
'step': step.name,
|
||
'so': so.name or '',
|
||
'status': recv or 'unknown',
|
||
})
|
||
return result
|
||
|
||
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
|
||
|
||
def write(self, vals):
|
||
"""Post a chatter trail on the parent JOB whenever an active
|
||
step gets reassigned. The step itself already tracks
|
||
assigned_user_id (tracking=True) but supervisors don't open
|
||
each step's chatter — they read the job. Without a job-level
|
||
post the takeover is invisible.
|
||
|
||
Only fires for steps in active states (in_progress / paused)
|
||
so creating a draft job + assigning a step to someone doesn't
|
||
spam the job chatter. Comparing to the OLD assignment so we
|
||
don't post on the initial set-from-False either.
|
||
"""
|
||
post_for = []
|
||
if 'assigned_user_id' in vals:
|
||
new_uid = vals['assigned_user_id']
|
||
for step in self:
|
||
if step.state not in ('in_progress', 'paused'):
|
||
continue
|
||
old_uid = step.assigned_user_id.id
|
||
if not old_uid:
|
||
continue
|
||
if new_uid == old_uid:
|
||
continue
|
||
post_for.append((step, old_uid, new_uid))
|
||
result = super().write(vals)
|
||
Users = self.env['res.users']
|
||
for step, old_uid, new_uid in post_for:
|
||
old_name = Users.browse(old_uid).name if old_uid else '(unassigned)'
|
||
new_name = Users.browse(new_uid).name if new_uid else '(unassigned)'
|
||
step.job_id.message_post(body=Markup(_(
|
||
'Step <b>%s</b> reassigned from <b>%s</b> to <b>%s</b> '
|
||
'(state=%s) by %s.'
|
||
)) % (step.name, old_name, new_name, step.state,
|
||
self.env.user.name))
|
||
return result
|
||
|
||
@api.model
|
||
def _cron_nudge_stale_paused(self, threshold_hours=24):
|
||
"""Daily nudge for steps stuck in `paused` longer than threshold."""
|
||
return self._cron_nudge_stale_steps(
|
||
states=('paused',),
|
||
threshold_hours=threshold_hours,
|
||
label='paused',
|
||
)
|
||
|
||
@api.model
|
||
def _cron_nudge_stale_in_progress(self, threshold_hours=8):
|
||
"""Cron nudge for steps stuck in `in_progress` longer than
|
||
threshold. Default 8 hours — operator started, walked away,
|
||
timelog accumulating phantom hours.
|
||
"""
|
||
return self._cron_nudge_stale_steps(
|
||
states=('in_progress',),
|
||
threshold_hours=threshold_hours,
|
||
label='in-progress',
|
||
)
|
||
|
||
@api.model
|
||
def _cron_nudge_stale_steps(self, states=('paused',),
|
||
threshold_hours=24, label='stale'):
|
||
"""Generic stale-step nudger.
|
||
|
||
Finds every fp.job.step in any of `states` with date_started
|
||
older than N hours. Schedules a 'todo' mail.activity on the
|
||
parent job for the job's manager_id (falls back to the user
|
||
who started the step). Idempotent — won't double-schedule if
|
||
an open activity with the same summary already exists.
|
||
"""
|
||
from datetime import timedelta as _td
|
||
cutoff = fields.Datetime.now() - _td(hours=threshold_hours)
|
||
stale = self.search([
|
||
('state', 'in', list(states)),
|
||
('date_started', '<', cutoff),
|
||
('date_started', '!=', False),
|
||
])
|
||
Activity = self.env['mail.activity']
|
||
ActivityType = self.env.ref(
|
||
'mail.mail_activity_data_todo', raise_if_not_found=False,
|
||
)
|
||
nudged_count = 0
|
||
for step in stale:
|
||
job = step.job_id
|
||
assignee = (job.manager_id or step.assigned_user_id
|
||
or step.started_by_user_id or self.env.user)
|
||
summary = _('Stale %s step: %s') % (label, step.name)
|
||
existing = Activity.search([
|
||
('res_model', '=', job._name),
|
||
('res_id', '=', job.id),
|
||
('summary', '=', summary),
|
||
], limit=1)
|
||
if existing:
|
||
continue
|
||
age_h = (fields.Datetime.now() - step.date_started).total_seconds() / 3600.0
|
||
note = _(
|
||
'Step "%(step)s" on job %(job)s has been in %(label)s state for '
|
||
'%(hours).1f hours (since %(start)s). Investigate: operator '
|
||
'reassignment, equipment failure, or finish + close out.'
|
||
) % {
|
||
'step': step.name, 'job': job.name, 'label': label,
|
||
'hours': age_h, 'start': step.date_started,
|
||
}
|
||
vals = {
|
||
'res_model_id': self.env['ir.model']._get(job._name).id,
|
||
'res_id': job.id,
|
||
'summary': summary,
|
||
'note': note,
|
||
'user_id': assignee.id,
|
||
'date_deadline': fields.Date.context_today(self),
|
||
}
|
||
if ActivityType:
|
||
vals['activity_type_id'] = ActivityType.id
|
||
Activity.create(vals)
|
||
nudged_count += 1
|
||
job.message_post(body=Markup(_(
|
||
'Stale %s step: <b>%s</b> has been idle %.1f hours. '
|
||
'Activity created for %s.'
|
||
)) % (label, step.name, age_h, assignee.name))
|
||
if nudged_count:
|
||
_logger.info(
|
||
'fp.job.step stale-%s cron: nudged %d step(s)',
|
||
label, nudged_count,
|
||
)
|
||
return nudged_count
|
||
|
||
def action_abort_for_retry(self, reason=None, new_tank_id=None,
|
||
new_bath_id=None):
|
||
"""Abort an in_progress / paused step so the operator can restart
|
||
it (typically after an equipment failure mid-step).
|
||
|
||
Closes the open timelog (preserves the partial-work record on
|
||
the audit trail), posts a clear chatter event on the JOB
|
||
explaining why + which tank, optionally moves the step to a
|
||
different tank/bath, and resets the step to `ready` so the
|
||
operator can hit Start again.
|
||
|
||
Without this method the operator's only options are
|
||
button_cancel (kills the step entirely) or
|
||
pause → write tank → start (no failure audit).
|
||
"""
|
||
if not reason:
|
||
reason = _('Equipment failure / abort for retry')
|
||
for step in self:
|
||
if step.state not in ('in_progress', 'paused'):
|
||
raise UserError(_(
|
||
"Step '%s' is in state '%s' — only in_progress / "
|
||
"paused steps can be aborted for retry."
|
||
) % (step.name, step.state))
|
||
old_tank = step.tank_id.display_name or '(no tank set)'
|
||
old_bath = step.bath_id.display_name or '(no bath set)'
|
||
now = fields.Datetime.now()
|
||
open_logs = step.time_log_ids.filtered(
|
||
lambda l: not l.date_finished
|
||
)
|
||
if open_logs:
|
||
open_logs.write({'date_finished': now})
|
||
partial_min = sum(step.time_log_ids.mapped('duration_minutes'))
|
||
change_msg = ''
|
||
if new_tank_id:
|
||
step.tank_id = new_tank_id
|
||
change_msg += ' -> tank %s' % step.tank_id.display_name
|
||
if new_bath_id:
|
||
step.bath_id = new_bath_id
|
||
change_msg += ' -> bath %s' % step.bath_id.display_name
|
||
step.state = 'ready'
|
||
step.duration_actual = partial_min
|
||
step.job_id.message_post(body=Markup(_(
|
||
'⚠️ Step <b>%s</b> aborted for retry by %s.<br/>'
|
||
'Reason: <em>%s</em><br/>'
|
||
'Equipment: tank=%s, bath=%s%s<br/>'
|
||
'Partial work captured: %.2f min in %d timelog(s). '
|
||
'Step is back in <b>ready</b> state — operator can '
|
||
'restart when the issue is resolved.'
|
||
)) % (
|
||
step.name, self.env.user.name, reason,
|
||
old_tank, old_bath, change_msg, partial_min,
|
||
len(step.time_log_ids),
|
||
))
|
||
return True
|
||
|
||
def action_recompute_duration_from_timelogs(self):
|
||
"""Re-sum duration_actual from the step's timelog rows.
|
||
|
||
Use case: supervisor adjusts a timelog row (back-date a forgotten
|
||
click, fix wrong operator, delete a stale entry that was left
|
||
open over a shift change) and needs the step's duration_actual
|
||
to reflect the corrected reality. Without this, edits to time_log_ids
|
||
rows don't propagate into duration_actual (which is set once
|
||
by button_finish).
|
||
|
||
Posts the before/after to chatter for audit.
|
||
"""
|
||
for step in self:
|
||
old = step.duration_actual or 0.0
|
||
new = sum(step.time_log_ids.mapped('duration_minutes'))
|
||
step.duration_actual = new
|
||
if abs(old - new) > 0.001:
|
||
step.job_id.message_post(body=Markup(_(
|
||
'Step <b>%s</b> duration recomputed from timelog rows: '
|
||
'%.2f min → %.2f min (Δ %+.2f). Recomputed by %s.'
|
||
)) % (step.name, old, new, new - old, self.env.user.name))
|
||
return True
|
||
|
||
def button_finish(self):
|
||
"""Override to:
|
||
1) Auto-spawn a bake.window when a wet plating step finishes
|
||
on a coating that requires hydrogen-embrittlement relief
|
||
(AS9100 / Nadcap compliance);
|
||
2) Post a chatter warning when duration_actual exceeds 1.5×
|
||
duration_expected — silent overruns are a red flag for
|
||
scheduling and costing.
|
||
|
||
Both actions are idempotent and never block the finish itself.
|
||
"""
|
||
result = super().button_finish()
|
||
BW = self.env['fusion.plating.bake.window']
|
||
Bath = self.env['fusion.plating.bath']
|
||
for step in self:
|
||
if step.state != 'done':
|
||
continue
|
||
# Duration-overrun chatter alert.
|
||
if step.duration_expected and step.duration_actual:
|
||
ratio = step.duration_actual / step.duration_expected
|
||
if ratio >= 1.5:
|
||
step.job_id.message_post(body=Markup(_(
|
||
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
|
||
'expected %.0f min, actual %.0f min. Investigate: '
|
||
'equipment issue, training gap, or recipe time '
|
||
'estimate too tight.'
|
||
)) % (step.name, ratio, step.duration_expected,
|
||
step.duration_actual))
|
||
coating = step.job_id.coating_config_id \
|
||
if 'coating_config_id' in step.job_id._fields else False
|
||
if not coating:
|
||
continue
|
||
requires = getattr(coating, 'requires_bake_relief', False)
|
||
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
|
||
if not requires or not window_hrs:
|
||
continue
|
||
# Trigger only on the actual plating-out step. We want
|
||
# exactly ONE bake.window per job (not one per step that
|
||
# happens to have "plate" in the name). Heuristic:
|
||
# - step.kind == 'wet' (clean, recipe-authored signal); OR
|
||
# - the step name contains "plating" as a word
|
||
# Explicit excludes: inspection / bake / mask / rack steps
|
||
# whose names might happen to mention plating in passing
|
||
# (e.g. "Post-plate Inspection").
|
||
name_l = (step.name or '').lower()
|
||
kind_match = step.kind == 'wet'
|
||
name_match = bool(re.search(r'\bplating\b', name_l))
|
||
excluded = any(kw in name_l for kw in (
|
||
'inspect', 'inspection', 'bake', 'mask', 'rack',
|
||
))
|
||
if (not kind_match and not name_match) or excluded:
|
||
continue
|
||
# Idempotency — only one bake.window per (job, step).
|
||
existing = BW.sudo().search([
|
||
('part_ref', '=', step.job_id.name),
|
||
('lot_ref', '=', f'step-{step.id}'),
|
||
], limit=1)
|
||
if existing:
|
||
continue
|
||
# Pick a bath: step.bath_id wins; fall back to the first
|
||
# active bath in the facility (best-effort — operator can
|
||
# correct on the bake.window record).
|
||
bath = step.bath_id or Bath.sudo().search(
|
||
[('facility_id', '=', step.facility_id.id)], limit=1,
|
||
) if step.facility_id else False
|
||
if not bath:
|
||
bath = Bath.sudo().search([], limit=1)
|
||
if not bath:
|
||
_logger.warning(
|
||
'Step %s: bake-window auto-spawn skipped — no bath '
|
||
'configured.', step.name,
|
||
)
|
||
continue
|
||
bw = BW.sudo().create({
|
||
'bath_id': bath.id,
|
||
'plate_exit_time': step.date_finished or fields.Datetime.now(),
|
||
'window_hours': window_hrs,
|
||
'part_ref': step.job_id.name,
|
||
'lot_ref': f'step-{step.id}',
|
||
'customer_ref': step.job_id.partner_id.display_name or '',
|
||
'quantity': int(step.job_id.qty or 0),
|
||
})
|
||
step.job_id.message_post(body=Markup(_(
|
||
'Bake window <b>%s</b> auto-created — %.1fh window from '
|
||
'plate exit. Required by %s.'
|
||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||
return result
|
||
|
||
# ==================================================================
|
||
# Phase 2 multi-serial — auto-promote serials on step transitions
|
||
# ==================================================================
|
||
def _fp_promote_serials_on_start(self):
|
||
"""When this step transitions to in_progress, lift any serial
|
||
attached to the parent SO line out of `received` / `racked` and
|
||
into `in_process`. Idempotent — already-promoted serials are
|
||
skipped.
|
||
"""
|
||
for step in self:
|
||
job = step.job_id
|
||
if not job.sale_order_line_ids:
|
||
continue
|
||
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
|
||
to_promote = serials.filtered(
|
||
lambda s: s.state in ('received', 'racked')
|
||
)
|
||
if to_promote:
|
||
# Use sudo on the helper so operator-tier users can promote
|
||
# serial state without needing direct write on fp.serial.
|
||
to_promote.sudo()._set_state('in_process', message=_(
|
||
'Promoted to In Process on step "%s" start by %s.'
|
||
) % (step.name, self.env.user.name))
|
||
|
||
def _fp_promote_serials_on_finish(self):
|
||
"""When the LAST step of this step's job finishes (sequenced
|
||
terminal step OR an explicit inspect/final-inspect kind), bump
|
||
in-flight serials to `inspected` so the shipper sees them ready
|
||
for packing. Conservative — only promotes from `in_process`."""
|
||
for step in self:
|
||
job = step.job_id
|
||
if not job.sale_order_line_ids:
|
||
continue
|
||
# Is this the highest-sequence non-cancelled step on the job?
|
||
siblings = job.step_ids.filtered(
|
||
lambda s: s.state not in ('cancelled', 'skipped')
|
||
)
|
||
if not siblings:
|
||
continue
|
||
last_seq = max(siblings.mapped('sequence'))
|
||
is_terminal = (step.sequence == last_seq) or (
|
||
step.kind == 'inspect' or 'final' in (step.name or '').lower()
|
||
)
|
||
if not is_terminal:
|
||
continue
|
||
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
|
||
to_promote = serials.filtered(lambda s: s.state == 'in_process')
|
||
if to_promote:
|
||
to_promote.sudo()._set_state('inspected', message=_(
|
||
'Promoted to Inspected on step "%s" finish by %s.'
|
||
) % (step.name, self.env.user.name))
|
||
|
||
# ==================================================================
|
||
# Policy B (2026-04-28) — Contract Review enforcement
|
||
# ==================================================================
|
||
# When a recipe author drops a "Contract Review" step into a recipe,
|
||
# button_start opens the QA-005 audit form for the linked part (auto-
|
||
# creates one if missing) and button_finish blocks completion until
|
||
# the form is `complete` AND the current user is on the recipe's
|
||
# contract_review_user_ids approver list (when configured).
|
||
#
|
||
# Detection — case-insensitive match on the step name OR
|
||
# recipe_node_id mapped from a step.template with default_kind ==
|
||
# 'contract_review' (the simple-editor library entry).
|
||
def _fp_is_contract_review_step(self):
|
||
self.ensure_one()
|
||
if (self.name or '').strip().lower() in ('contract review', 'qa-005'):
|
||
return True
|
||
node = self.recipe_node_id
|
||
if not node:
|
||
return False
|
||
# Source template kind (when authored via simple editor library)
|
||
if 'source_template_id' in node._fields and node.source_template_id:
|
||
if node.source_template_id.default_kind == 'contract_review':
|
||
return True
|
||
if 'default_kind' in node._fields and node.default_kind == 'contract_review':
|
||
return True
|
||
return False
|
||
|
||
def _fp_resolve_contract_review_part(self):
|
||
"""Find the fp.part.catalog this step's job is for. Used by the
|
||
Contract Review hooks to auto-create / look up the QA-005 form.
|
||
Falls through to None when no part can be resolved (no SO line,
|
||
SO line without x_fc_part_catalog_id, etc.)."""
|
||
self.ensure_one()
|
||
for so_line in self.job_id.sale_order_line_ids:
|
||
if (so_line.x_fc_part_catalog_id
|
||
and 'fp.contract.review' in self.env):
|
||
return so_line.x_fc_part_catalog_id
|
||
return None
|
||
|
||
def _fp_open_contract_review(self):
|
||
"""Auto-create the QA-005 form for this step's part if missing,
|
||
return the act_window pointing at it. Called from button_start
|
||
on Contract Review steps."""
|
||
self.ensure_one()
|
||
part = self._fp_resolve_contract_review_part()
|
||
if not part:
|
||
return None
|
||
Review = self.env.get('fp.contract.review')
|
||
if Review is None:
|
||
return None # quality module not installed — skip
|
||
review = part.x_fc_contract_review_id
|
||
if not review:
|
||
review = Review.sudo().create({
|
||
'part_id': part.id,
|
||
'state': 'assistant_review',
|
||
})
|
||
part.sudo().write({
|
||
'x_fc_contract_review_id': review.id,
|
||
'x_fc_contract_review_dismissed': False,
|
||
})
|
||
self.job_id.message_post(body=_(
|
||
'Contract Review (QA-005) auto-created for %(part)s on '
|
||
'Contract Review step start by %(user)s.'
|
||
) % {
|
||
'part': part.display_name or part.part_number or '',
|
||
'user': self.env.user.name,
|
||
})
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'fp.contract.review',
|
||
'res_id': review.id,
|
||
'view_mode': 'form',
|
||
'target': 'current',
|
||
'name': _('Contract Review — %s') % (
|
||
part.display_name or part.part_number or ''
|
||
),
|
||
}
|
||
|
||
def _fp_check_contract_review_complete(self):
|
||
"""Block button_finish on a Contract Review step until QA-005 is
|
||
signed off. Only enforced when the customer has
|
||
partner.x_fc_contract_review_required=True. Manager bypass via
|
||
context fp_skip_contract_review_gate=True."""
|
||
if self.env.context.get('fp_skip_contract_review_gate'):
|
||
return
|
||
for step in self:
|
||
if not step._fp_is_contract_review_step():
|
||
continue
|
||
part = step._fp_resolve_contract_review_part()
|
||
if not part or not part.partner_id.x_fc_contract_review_required:
|
||
continue
|
||
review = part.x_fc_contract_review_id
|
||
if not review or review.state != 'complete':
|
||
state_label = (
|
||
review.state if review else _('not started')
|
||
)
|
||
raise UserError(_(
|
||
'Contract Review for %(part)s is %(state)s — must be '
|
||
'"complete" before this step can finish. Open the '
|
||
'QA-005 form (smart button on the part), get both '
|
||
'sections signed off, then retry. Manager bypass: '
|
||
'fp_skip_contract_review_gate=True in context.'
|
||
) % {
|
||
'part': part.display_name or part.part_number or '',
|
||
'state': state_label,
|
||
})
|
||
# Approver-list gate (restored from pre-Sub-11). When the
|
||
# recipe author named approvers on the recipe root, only those
|
||
# users can finish the Contract Review step.
|
||
recipe = step.recipe_node_id and step.recipe_node_id.recipe_root_id
|
||
approvers = (recipe.contract_review_user_ids
|
||
if (recipe and 'contract_review_user_ids' in recipe._fields)
|
||
else False)
|
||
if approvers and self.env.user not in approvers:
|
||
raise UserError(_(
|
||
'Only authorised Contract Review approvers can finish '
|
||
'this step. Approvers: %s.\n\nContact your Plating '
|
||
'Manager to add yourself if this is wrong, or hand '
|
||
'the step to one of the approvers.'
|
||
) % ', '.join(approvers.mapped('name')))
|
||
|
||
# ==================================================================
|
||
# Sub 8 follow-up (2026-04-28) — Racking Inspection enforcement
|
||
# ==================================================================
|
||
# When the recipe-side "Racking" step starts, auto-promote the linked
|
||
# fp.racking.inspection from draft → inspecting and route the operator
|
||
# straight into the inspection form. When the same step finishes,
|
||
# block unless the inspection is in `done` or `discrepancy_flagged`
|
||
# (operator cleared every line). Manager bypass via context
|
||
# `fp_skip_racking_inspection_gate=True`.
|
||
def _fp_is_racking_step(self):
|
||
self.ensure_one()
|
||
if (self.name or '').strip().lower() in ('racking', 'rack'):
|
||
return True
|
||
node = self.recipe_node_id
|
||
if not node:
|
||
return False
|
||
if 'source_template_id' in node._fields and node.source_template_id:
|
||
if node.source_template_id.default_kind == 'racking':
|
||
return True
|
||
if 'default_kind' in node._fields and node.default_kind == 'racking':
|
||
return True
|
||
if self.kind == 'rack':
|
||
return True
|
||
return False
|
||
|
||
def _fp_open_racking_inspection(self):
|
||
"""Auto-promote draft → inspecting + return act_window for the
|
||
linked racking inspection. Auto-creates one if missing."""
|
||
self.ensure_one()
|
||
if 'fp.racking.inspection' not in self.env:
|
||
return None
|
||
# Reach the job's existing inspection (auto-created on action_confirm)
|
||
# or trigger a fresh create if none exists.
|
||
ri = self.job_id.racking_inspection_id
|
||
if not ri:
|
||
self.job_id._fp_create_racking_inspection()
|
||
self.job_id.invalidate_recordset(['racking_inspection_ids'])
|
||
ri = self.job_id.racking_inspection_id
|
||
if not ri:
|
||
return None
|
||
# Promote draft → inspecting. action_start raises if state isn't
|
||
# draft, so guard.
|
||
if ri.state == 'draft':
|
||
ri.sudo().action_start()
|
||
self.job_id.message_post(body=_(
|
||
'Racking inspection auto-promoted to "Inspecting" on '
|
||
'%(step)s start by %(user)s.'
|
||
) % {'step': self.name, 'user': self.env.user.name})
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'fp.racking.inspection',
|
||
'res_id': ri.id,
|
||
'view_mode': 'form',
|
||
'target': 'current',
|
||
'name': _('Racking Inspection — %s') % self.job_id.name,
|
||
}
|
||
|
||
def _fp_check_racking_inspection_complete(self):
|
||
"""Soft gate — block button_finish on a Racking step until the
|
||
linked inspection is in a terminal state. discrepancy_flagged
|
||
counts as complete (the operator finished but flagged issues —
|
||
the discrepancy activity will route to the manager separately)."""
|
||
if self.env.context.get('fp_skip_racking_inspection_gate'):
|
||
return
|
||
for step in self:
|
||
if not step._fp_is_racking_step():
|
||
continue
|
||
ri = step.job_id.racking_inspection_id
|
||
if not ri:
|
||
# No inspection at all — still let it finish, but log a
|
||
# chatter warning so the manager sees the gap.
|
||
step.job_id.message_post(body=_(
|
||
'⚠️ Racking step "%s" finished without a racking '
|
||
'inspection on file. Sub 8 expected one to be '
|
||
'auto-created on job confirm.'
|
||
) % step.name)
|
||
continue
|
||
if ri.state not in ('done', 'discrepancy_flagged'):
|
||
state_label = dict(ri._fields['state'].selection).get(
|
||
ri.state, ri.state)
|
||
raise UserError(_(
|
||
'Racking inspection for %(job)s is "%(state)s" — must '
|
||
'be Done or Discrepancy Flagged before this step can '
|
||
'finish. Click the Racking Insp. smart button on the '
|
||
'job, complete the line check-off, then retry. '
|
||
'Manager bypass: fp_skip_racking_inspection_gate=True.'
|
||
) % {
|
||
'job': step.job_id.name,
|
||
'state': state_label,
|
||
})
|
||
|
||
def button_start(self):
|
||
# Policy B — Contract Review takes priority (auto-opens QA-005).
|
||
for step in self:
|
||
if step._fp_is_contract_review_step():
|
||
action = step._fp_open_contract_review()
|
||
if action:
|
||
super(FpJobStep, step).button_start()
|
||
if step.state == 'in_progress':
|
||
step._fp_promote_serials_on_start()
|
||
return action
|
||
# Sub 8 — Racking step auto-opens the inspection form.
|
||
for step in self:
|
||
if step._fp_is_racking_step():
|
||
action = step._fp_open_racking_inspection()
|
||
if action:
|
||
super(FpJobStep, step).button_start()
|
||
if step.state == 'in_progress':
|
||
step._fp_promote_serials_on_start()
|
||
return action
|
||
result = super().button_start()
|
||
for step in self:
|
||
if step.state == 'in_progress':
|
||
step._fp_promote_serials_on_start()
|
||
return result
|
||
|
||
def button_finish(self):
|
||
# Policy B — block until QA-005 complete (when customer requires it).
|
||
self._fp_check_contract_review_complete()
|
||
# Sub 8 — block until racking inspection is Done / Flagged.
|
||
self._fp_check_racking_inspection_complete()
|
||
result = super().button_finish()
|
||
for step in self:
|
||
if step.state == 'done':
|
||
step._fp_promote_serials_on_finish()
|
||
return result
|
||
|
||
# ==================================================================
|
||
# Per-row shortcut actions used by the job form's inline action column
|
||
# ==================================================================
|
||
def action_open_move_wizard(self):
|
||
"""Open the Move wizard with this step pre-filled as the from-step."""
|
||
self.ensure_one()
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'fp.job.step.move.wizard',
|
||
'view_mode': 'form',
|
||
'target': 'new',
|
||
'name': _('Move from %s') % self.name,
|
||
'context': {
|
||
'default_from_step_id': self.id,
|
||
'default_job_id': self.job_id.id,
|
||
},
|
||
}
|
||
|
||
def action_open_input_wizard(self):
|
||
"""Open the Input Recording wizard for this step."""
|
||
self.ensure_one()
|
||
return {
|
||
'type': 'ir.actions.act_window',
|
||
'res_model': 'fp.job.step.input.wizard',
|
||
'view_mode': 'form',
|
||
'target': 'new',
|
||
'name': _('Record Inputs — %s') % self.name,
|
||
'context': {
|
||
'default_step_id': self.id,
|
||
},
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Live duration helper — view binds to a non-stored compute that
|
||
# ticks each time the form re-reads. For a true live ticking clock
|
||
# we'd need an OWL widget; this gives "minutes since start" that's
|
||
# accurate at every record refresh, which is good enough for a
|
||
# backend manager's view.
|
||
# ------------------------------------------------------------------
|
||
duration_running_minutes = fields.Float(
|
||
string='Running Min',
|
||
compute='_compute_duration_running',
|
||
help='Minutes since the step\'s current open timelog started. '
|
||
'Re-reads on every form refresh; equals duration_actual once '
|
||
'the step is finished.',
|
||
)
|
||
|
||
@api.depends('state', 'date_started', 'time_log_ids',
|
||
'time_log_ids.date_started', 'time_log_ids.date_finished',
|
||
'duration_actual')
|
||
def _compute_duration_running(self):
|
||
now = fields.Datetime.now()
|
||
for step in self:
|
||
if step.state == 'in_progress':
|
||
# Sum closed intervals + (now - open interval start)
|
||
closed = sum(step.time_log_ids.mapped('duration_minutes'))
|
||
open_log = step.time_log_ids.filtered(
|
||
lambda l: not l.date_finished
|
||
)[:1]
|
||
running = 0.0
|
||
if open_log and open_log.date_started:
|
||
delta = (now - open_log.date_started).total_seconds() / 60.0
|
||
running = max(0.0, delta)
|
||
step.duration_running_minutes = closed + running
|
||
else:
|
||
step.duration_running_minutes = step.duration_actual or 0.0
|