Pre-super: when finishing the last open step on an in_progress job, run the bake/qty/QC gates from button_mark_done so failures surface as UserError on the click (per spec D12). Without this the auto-advance would silently fail with no error path. Post-super: trigger _fp_check_advance_post_shop so the state auto-advances cleanly (in_progress → awaiting_cert / awaiting_ship). Added _fp_check_finish_gates helper on fp.job and a fp_check_gates_only context flag honored by button_mark_done so the gate logic is single-sourced (DRY). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1720 lines
76 KiB
Python
1720 lines
76 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__)
|
|
|
|
|
|
# 2026-05-24 — Shop Floor live-step fix (19.0.10.24.0):
|
|
# The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed.
|
|
# fp.step.kind now self-declares its area_kind, so the kind taxonomy
|
|
# IS the source of truth for Shop Floor column routing.
|
|
# See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
|
|
|
|
|
class FpJobStep(models.Model):
|
|
_inherit = 'fp.job.step'
|
|
|
|
# ===== Sub 13 — sequential enforcement (recipe + per-step) =============
|
|
# Decision matrix for whether button_start must verify predecessors:
|
|
#
|
|
# recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
|
|
# --------------------------|---------------------|------------------------|------
|
|
# True | False | any | YES
|
|
# True | True | any | no
|
|
# False | any | True | YES
|
|
# False | any | False | no
|
|
#
|
|
# Manager bypass via context fp_skip_predecessor_check=True.
|
|
# Encapsulated in _fp_should_block_predecessors() so the same gate
|
|
# is reused by button_start and the Move wizard's predecessor check
|
|
# (fusion_plating_shopfloor/controllers/move_controller.py).
|
|
# ======================================================================
|
|
|
|
def _fp_should_block_predecessors(self):
|
|
"""Return True if this step must verify that every earlier-
|
|
sequence step is in a terminal state before it can start.
|
|
See decision matrix in the section header above.
|
|
"""
|
|
self.ensure_one()
|
|
# Defensive: jobs without a recipe (manual-build jobs) default
|
|
# to enforce-on so freshly-created records don't accidentally
|
|
# leak permissive behaviour through a related-field None.
|
|
if not self.job_id:
|
|
return True
|
|
recipe_seq = self.job_id.enforce_sequential
|
|
if recipe_seq:
|
|
return not self.parallel_start
|
|
# Free-flow recipe — only the legacy per-step flag still gates.
|
|
return bool(self.requires_predecessor_done)
|
|
|
|
def _fp_has_unfinished_predecessors(self):
|
|
"""True when an earlier-sequence step on the same job is not yet
|
|
in a terminal state. Composes with _fp_should_block_predecessors
|
|
to drive the plant-view predecessor_locked card state."""
|
|
self.ensure_one()
|
|
return bool(self.job_id.step_ids.filtered(
|
|
lambda s: s.sequence < self.sequence
|
|
and s.state not in ('done', 'skipped', 'cancelled')
|
|
))
|
|
|
|
can_start = fields.Boolean(
|
|
string='Can Start',
|
|
compute='_compute_can_start',
|
|
help='True when this step is in a startable state AND the '
|
|
'predecessor gate (if any) is currently clear. Drives the '
|
|
'tablet/job form Start button visibility.',
|
|
)
|
|
|
|
@api.depends(
|
|
'state',
|
|
'sequence',
|
|
'parallel_start',
|
|
'requires_predecessor_done',
|
|
'job_id.enforce_sequential',
|
|
'job_id.step_ids.state',
|
|
'job_id.step_ids.sequence',
|
|
)
|
|
def _compute_can_start(self):
|
|
for step in self:
|
|
if step.state not in ('pending', 'ready', 'paused'):
|
|
step.can_start = False
|
|
continue
|
|
if not step._fp_should_block_predecessors():
|
|
step.can_start = True
|
|
continue
|
|
blocking = step.job_id.step_ids.filtered(
|
|
lambda s: s.sequence < step.sequence and s.state not in (
|
|
'done', 'skipped', 'cancelled',
|
|
)
|
|
)
|
|
step.can_start = not bool(blocking)
|
|
|
|
# ===== 2026-05-23 plant-view redesign — area_kind + activity =========
|
|
area_kind = fields.Selection(
|
|
[
|
|
('receiving', 'Receiving'),
|
|
('masking', 'Masking'),
|
|
('blasting', 'Blasting'),
|
|
('racking', 'Racking'),
|
|
('plating', 'Plating'),
|
|
('baking', 'Baking'),
|
|
('de_racking', 'De-Racking'),
|
|
('inspection', 'Final inspection'),
|
|
('shipping', 'Shipping'),
|
|
],
|
|
string='Floor Column',
|
|
compute='_compute_area_kind',
|
|
store=True,
|
|
index=True,
|
|
help='Which Shop Floor column this step belongs to. Resolved as: '
|
|
'(1) work_centre.area_kind if set; else (2) the area_kind on '
|
|
'recipe_node.kind_id; else (3) the safe catch-all "plating". '
|
|
'Drives plant-view kanban grouping.',
|
|
)
|
|
|
|
@api.depends(
|
|
'work_centre_id.area_kind',
|
|
'recipe_node_id.kind_id.area_kind',
|
|
)
|
|
def _compute_area_kind(self):
|
|
"""Resolve the plant-view column this step belongs in.
|
|
|
|
Priority chain:
|
|
1. work_centre.area_kind (explicit operator setup wins)
|
|
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
|
3. catch-all 'plating' (data integrity issue if we land here)
|
|
|
|
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
|
|
now self-declares its area_kind, so the kind taxonomy IS the
|
|
source of truth. See spec
|
|
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
|
"""
|
|
for step in self:
|
|
# 1. Explicit work_centre wins
|
|
if step.work_centre_id and step.work_centre_id.area_kind:
|
|
step.area_kind = step.work_centre_id.area_kind
|
|
continue
|
|
# 2. Kind taxonomy
|
|
node = step.recipe_node_id
|
|
if node and node.kind_id and node.kind_id.area_kind:
|
|
step.area_kind = node.kind_id.area_kind
|
|
continue
|
|
# 3. Catch-all — only reached for orphaned steps (no
|
|
# work_centre AND no recipe_node).
|
|
step.area_kind = 'plating'
|
|
|
|
last_activity_at = fields.Datetime(
|
|
string='Last Activity',
|
|
index=True,
|
|
help='Stamped on any state transition, move-out from this step, '
|
|
'or chatter post. Drives the S16 idle-warning card state '
|
|
'(in_progress with no activity for 8+ hours).',
|
|
)
|
|
|
|
def _fp_is_idle(self, threshold_hours=8):
|
|
"""True when this step is in_progress AND last_activity_at is older
|
|
than `threshold_hours`. Drives the idle_warning card state."""
|
|
self.ensure_one()
|
|
if self.state != 'in_progress':
|
|
return False
|
|
if not self.last_activity_at:
|
|
return False
|
|
delta = fields.Datetime.now() - self.last_activity_at
|
|
return delta.total_seconds() > threshold_hours * 3600
|
|
|
|
def message_post(self, **kwargs):
|
|
"""Override: stamp last_activity_at so an operator note counts as
|
|
activity (defeats false-positive idle warnings during long bakes
|
|
where the only sign of life is the periodic operator note)."""
|
|
res = super().message_post(**kwargs)
|
|
try:
|
|
self.sudo().with_context(tracking_disable=True).write({
|
|
'last_activity_at': fields.Datetime.now(),
|
|
})
|
|
except Exception as exc:
|
|
_logger.debug("last_activity_at stamp on message_post failed: %s", exc)
|
|
return res
|
|
|
|
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
|
# Returns kind of blocker + human reason + optional (model, id) jump
|
|
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
|
# with can_start as a single source of truth.
|
|
blocker_kind = fields.Selection(
|
|
[
|
|
('none', 'Not blocked'),
|
|
('predecessor', 'Waiting on predecessor'),
|
|
('contract_review', 'Contract review pending'),
|
|
('parts_not_received', 'Parts not received'),
|
|
('racking_required', 'Racking inspection required'),
|
|
('manager_input', 'Manager input required'),
|
|
('other', 'Other'),
|
|
],
|
|
compute='_compute_blocker',
|
|
string='Blocker Kind',
|
|
)
|
|
blocker_reason = fields.Char(
|
|
compute='_compute_blocker',
|
|
string='Blocker Reason',
|
|
help='Human-readable explanation surfaced in the GateViz block.',
|
|
)
|
|
blocker_jump_target_model = fields.Char(compute='_compute_blocker')
|
|
blocker_jump_target_id = fields.Integer(compute='_compute_blocker')
|
|
|
|
@api.depends(
|
|
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
|
'job_id.enforce_sequential',
|
|
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
|
)
|
|
def _compute_blocker(self):
|
|
for step in self:
|
|
# Terminal/in-progress states are never "blocked"
|
|
if step.state in ('done', 'skipped', 'cancelled', 'in_progress'):
|
|
step.blocker_kind = 'none'
|
|
step.blocker_reason = ''
|
|
step.blocker_jump_target_model = False
|
|
step.blocker_jump_target_id = 0
|
|
continue
|
|
|
|
# Predecessor gate — same policy as _compute_can_start
|
|
if step._fp_should_block_predecessors():
|
|
earlier_open = step.job_id.step_ids.filtered(lambda x: (
|
|
x.id != step.id
|
|
and x.sequence < step.sequence
|
|
and x.state not in ('done', 'skipped', 'cancelled')
|
|
))
|
|
if earlier_open:
|
|
first_blocker = earlier_open.sorted('sequence')[0]
|
|
step.blocker_kind = 'predecessor'
|
|
seq_disp = (first_blocker.sequence or 0) // 10
|
|
step.blocker_reason = (
|
|
f'Waiting on Step {seq_disp}: {first_blocker.name}'
|
|
)
|
|
step.blocker_jump_target_model = 'fp.job.step'
|
|
step.blocker_jump_target_id = first_blocker.id
|
|
continue
|
|
|
|
# Future: extend with explicit checks for contract_review /
|
|
# parts_not_received / racking_required / manager_input as
|
|
# those gate models mature. For now, default to 'none'.
|
|
step.blocker_kind = 'none'
|
|
step.blocker_reason = ''
|
|
step.blocker_jump_target_model = False
|
|
step.blocker_jump_target_id = 0
|
|
|
|
# ==================================================================
|
|
# Shop-Floor auto-pause cron (Phase 2 — tablet redesign)
|
|
# ==================================================================
|
|
@api.model
|
|
def _cron_autopause_stale_steps(self):
|
|
"""Flip in_progress steps idle > threshold to paused.
|
|
|
|
Threshold read from ir.config_parameter
|
|
fp.shopfloor.autopause_threshold_hours (default 8.0)
|
|
|
|
Recipes can opt out per node via
|
|
fusion.plating.process.node.long_running (Phase 2 — P2.1)
|
|
|
|
Fixes the 411-hour ghost timer that bit us on the original tablet
|
|
when an operator started a step and never tapped Finish. Posts an
|
|
audit chatter entry on the step so the operator can see what
|
|
happened when they resume.
|
|
"""
|
|
from datetime import timedelta
|
|
threshold = float(
|
|
self.env['ir.config_parameter'].sudo()
|
|
.get_param('fp.shopfloor.autopause_threshold_hours', 8)
|
|
)
|
|
deadline = fields.Datetime.now() - timedelta(hours=threshold)
|
|
domain = [
|
|
('state', '=', 'in_progress'),
|
|
('date_started', '<', deadline),
|
|
'|',
|
|
('recipe_node_id', '=', False),
|
|
('recipe_node_id.long_running', '=', False),
|
|
]
|
|
stale = self.search(domain)
|
|
paused = 0
|
|
for step in stale:
|
|
try:
|
|
step.button_pause()
|
|
step.message_post(body=Markup(
|
|
"<b>Auto-paused</b> after %.1fh idle. "
|
|
"Resume from the tablet when work continues."
|
|
) % threshold)
|
|
_logger.info(
|
|
"Auto-paused step %s (%s) after %.1fh idle",
|
|
step.id, step.name, threshold,
|
|
)
|
|
paused += 1
|
|
except Exception:
|
|
_logger.exception(
|
|
"Auto-pause failed for step %s — skipping", step.id,
|
|
)
|
|
if paused:
|
|
_logger.info(
|
|
"_cron_autopause_stale_steps: paused %d step(s) "
|
|
"(threshold %.1fh)", paused, threshold,
|
|
)
|
|
return paused
|
|
|
|
# NOTE: the actual button_start override lives further down (~line
|
|
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
|
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
|
# check. Keeping ONE definition prevents the Python-overrides-the-
|
|
# earlier-method footgun that swallowed Sub 13 enforcement on
|
|
# WH/JOB/00342.
|
|
|
|
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))
|
|
# Plant-view: stamp last_activity_at on every state transition so
|
|
# the S16 idle gate has fresh data. Only stamp when state is in
|
|
# vals AND it's actually changing (avoid no-op writes spamming
|
|
# the timestamp).
|
|
if 'state' in vals and 'last_activity_at' not in vals:
|
|
new_state = vals['state']
|
|
if any(step.state != new_state for step in self):
|
|
vals = dict(vals, last_activity_at=fields.Datetime.now())
|
|
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))
|
|
|
|
# Auto-promote parent job: confirmed → in_progress on first
|
|
# step start. Without this, fp.job.state never reaches
|
|
# 'in_progress' anywhere in the codebase, so the In Progress
|
|
# workflow milestone (trigger_on_job_state='in_progress')
|
|
# never fires.
|
|
if vals.get('state') == 'in_progress':
|
|
jobs_to_promote = self.mapped('job_id').filtered(
|
|
lambda j: j.state == 'confirmed'
|
|
)
|
|
if jobs_to_promote:
|
|
jobs_to_promote.write({'state': 'in_progress'})
|
|
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):
|
|
"""Manual button — re-sum duration_actual + post to chatter
|
|
for audit. Use case: supervisor adjusts a timelog row and
|
|
wants an explicit audit trail of the recompute. The
|
|
automatic version called from timelog hooks is
|
|
_fp_resum_duration_actual (no chatter).
|
|
"""
|
|
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 _fp_resum_duration_actual(self):
|
|
"""Quiet re-sum — used by automatic triggers (timelog
|
|
create/write/unlink hooks). No chatter post. Skips no-op
|
|
updates so writes are minimised."""
|
|
for step in self:
|
|
new = sum(step.time_log_ids.mapped('duration_minutes'))
|
|
if abs((step.duration_actual or 0.0) - new) > 0.001:
|
|
step.duration_actual = new
|
|
return True
|
|
|
|
def action_finish_and_advance(self):
|
|
"""Steelhead-style "Finish & Next" — finish this step then auto-
|
|
start the next pending/ready step in sequence. Single click
|
|
replaces the prior Finish-then-Move-wizard dance.
|
|
|
|
If the step has authored step_input prompts AND none have been
|
|
captured yet, we route through the simplified Record Inputs
|
|
wizard first; saving the wizard re-enters here with the
|
|
`fp_after_inputs=True` context flag so we don't loop.
|
|
"""
|
|
self.ensure_one()
|
|
if self.state != 'in_progress':
|
|
raise UserError(_(
|
|
"Step '%s' is in state '%s' — start it before clicking Finish."
|
|
) % (self.name, self.state))
|
|
|
|
# Contract Review (QA-005) routing — when the recipe step is
|
|
# flagged as a contract-review step, the operator should land on
|
|
# the part's QA-005 form rather than the generic measurement
|
|
# wizard. Once the review is complete or dismissed we fall
|
|
# through to the normal finish path so the step can advance.
|
|
cr_action = self._fp_contract_review_redirect()
|
|
if cr_action:
|
|
return cr_action
|
|
|
|
# Prompt-first behaviour: show the Record Inputs dialog when the
|
|
# recipe step has authored prompts and nothing has been captured
|
|
# in this run. Bypass when context flag is set (i.e. we're being
|
|
# called BACK from the wizard's commit), or when the operator
|
|
# already saved values via the Record Inputs button earlier.
|
|
if (not self.env.context.get('fp_after_inputs')
|
|
and self._fp_has_uncaptured_step_inputs()):
|
|
return self._fp_open_input_wizard(advance_after=True)
|
|
|
|
# Auto-move shim: for qty_at_step==1 + downstream step,
|
|
# silently record a move(qty=1) so the qty gate in
|
|
# button_finish passes. Raises for qty>1 (operator must use
|
|
# Complete 1 → Next or Move…). Last step is always allowed.
|
|
self._fp_record_one_piece_auto_move()
|
|
self.button_finish()
|
|
next_step = self._fp_next_runnable_step()
|
|
if next_step:
|
|
next_step.with_context(
|
|
fp_skip_predecessor_check=True,
|
|
).button_start()
|
|
self.job_id.message_post(body=_(
|
|
'Step "%(prev)s" finished — auto-started next step "%(next)s".'
|
|
) % {'prev': self.name, 'next': next_step.name})
|
|
return True
|
|
|
|
def _fp_next_runnable_step(self):
|
|
"""The lowest-sequence step on this job that isn't terminal yet
|
|
and isn't this one. Used by action_finish_and_advance."""
|
|
self.ensure_one()
|
|
candidates = self.job_id.step_ids.filtered(
|
|
lambda s: s.id != self.id
|
|
and s.state in ('pending', 'ready', 'paused')
|
|
).sorted('sequence')
|
|
return candidates[:1] or self.env['fp.job.step']
|
|
|
|
def _fp_has_uncaptured_step_inputs(self):
|
|
"""True when the recipe step has REQUIRED step_input prompts
|
|
whose values haven't been recorded yet.
|
|
|
|
Previously this checked "any move with input values exists since
|
|
date_started" — too coarse. Operator clicked Save on the dialog
|
|
after filling ONE prompt and the helper went quiet, letting
|
|
action_finish_and_advance bypass the dialog re-open even when
|
|
4 of 5 required prompts were still empty (WO-30051 / Riya 2026-05-23).
|
|
Now we count actual coverage per required input across every
|
|
move recorded against this step.
|
|
"""
|
|
self.ensure_one()
|
|
return bool(self._fp_missing_required_step_inputs())
|
|
|
|
def _fp_missing_required_step_inputs(self):
|
|
"""Return the recordset of REQUIRED step_input prompts on this
|
|
step's recipe node that have NO value recorded across any move
|
|
from this step. Centralised helper — used by both
|
|
_fp_has_uncaptured_step_inputs (re-open dialog) and
|
|
_fp_check_step_inputs_complete (raise UserError on finish).
|
|
"""
|
|
self.ensure_one()
|
|
node = self.recipe_node_id
|
|
Prompt = self.env['fusion.plating.process.node.input']
|
|
if not node:
|
|
return Prompt
|
|
prompts = node.input_ids
|
|
if 'kind' in prompts._fields:
|
|
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
|
if 'collect' in prompts._fields:
|
|
prompts = prompts.filtered(lambda i: i.collect)
|
|
required_prompts = prompts.filtered(lambda i: i.required)
|
|
if not required_prompts:
|
|
return Prompt
|
|
Value = self.env['fp.job.step.move.input.value']
|
|
recorded_input_ids = set(Value.search([
|
|
('move_id.from_step_id', '=', self.id),
|
|
('node_input_id', 'in', required_prompts.ids),
|
|
]).mapped('node_input_id.id'))
|
|
return required_prompts.filtered(
|
|
lambda p: p.id not in recorded_input_ids
|
|
)
|
|
|
|
def _fp_autosign_if_required(self):
|
|
"""Auto-set signoff_user_id to the current user when the step has
|
|
requires_signoff=True and no signoff has been recorded yet.
|
|
|
|
Called from button_finish just before the signoff gate. Captures
|
|
WHO finished the step as the signer-of-record. For shops that
|
|
need separate operator+supervisor sign-off, call action_signoff()
|
|
explicitly from a supervisor session BEFORE the operator clicks
|
|
Finish — that pre-sets signoff_user_id and this helper becomes a
|
|
no-op.
|
|
|
|
Idempotent — never overwrites an existing signoff_user_id, so a
|
|
manager pre-signing via action_signoff is preserved through the
|
|
operator's Finish click.
|
|
"""
|
|
for step in self:
|
|
if not step.requires_signoff:
|
|
continue
|
|
if step.signoff_user_id:
|
|
continue # pre-signed (likely by a supervisor)
|
|
# Use sudo because signoff_user_id is readonly=True at field
|
|
# level; we still capture env.user.id (not SUPERUSER_ID) so
|
|
# the audit trail shows who actually clicked.
|
|
step.sudo().write({'signoff_user_id': self.env.user.id})
|
|
|
|
def _fp_check_signoff_complete(self):
|
|
"""Raise UserError if the step has requires_signoff=True and
|
|
signoff_user_id IS NULL. Aerospace / Nadcap need a named signer
|
|
on every sign-off-required step; an unset signer breaks the
|
|
audit chain.
|
|
|
|
Normally _fp_autosign_if_required (called from button_finish
|
|
immediately before this gate) populates signoff_user_id with the
|
|
finisher's id, so this gate only fires when:
|
|
- The step is being finished via a code path that bypasses
|
|
autosign (e.g. a migration script writing state='done').
|
|
- The user has no env.user (background cron with no uid set).
|
|
|
|
Manager bypass via context fp_skip_signoff_gate=True for
|
|
documented customer deviations. Bypasses are posted to chatter
|
|
naming the user.
|
|
"""
|
|
if self.env.context.get('fp_skip_signoff_gate'):
|
|
for step in self:
|
|
if not step.requires_signoff:
|
|
continue
|
|
step.job_id.message_post(body=Markup(_(
|
|
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
|
|
'Documented deviation — no signer recorded.'
|
|
)) % (step.name, self.env.user.name))
|
|
return
|
|
for step in self:
|
|
if not step.requires_signoff:
|
|
continue
|
|
if step.signoff_user_id:
|
|
continue
|
|
raise UserError(_(
|
|
'Step "%(step)s" cannot be finished — sign-off required '
|
|
'but no signer recorded. Click "Sign Off" on the step '
|
|
'(or have your supervisor sign before you finish). '
|
|
'Managers can override via context flag '
|
|
'fp_skip_signoff_gate=True for documented deviations.'
|
|
) % {'step': step.name})
|
|
|
|
def action_signoff(self):
|
|
"""Explicit sign-off action — sets signoff_user_id = env.user.id
|
|
for the calling user. Use case: a supervisor reviews an operator's
|
|
work and signs off BEFORE the operator clicks Finish. Once signed,
|
|
the operator's Finish click passes the signoff gate without auto-
|
|
assigning a different signer.
|
|
|
|
Idempotent — re-clicking by the same user is a no-op. A DIFFERENT
|
|
user re-signing overwrites the prior signer (and chatters the change)
|
|
so a senior supervisor can override a junior's premature sign-off
|
|
without leaving the audit trail mute.
|
|
"""
|
|
for step in self:
|
|
if not step.requires_signoff:
|
|
raise UserError(_(
|
|
'Step "%s" does not require sign-off — nothing to sign.'
|
|
) % step.name)
|
|
prior = step.signoff_user_id
|
|
if prior and prior.id == self.env.user.id:
|
|
continue # idempotent
|
|
step.sudo().write({'signoff_user_id': self.env.user.id})
|
|
if prior:
|
|
step.job_id.message_post(body=Markup(_(
|
|
'Sign-off on step "<b>%s</b>" reassigned from %s to %s.'
|
|
)) % (step.name, prior.name, self.env.user.name))
|
|
else:
|
|
step.job_id.message_post(body=Markup(_(
|
|
'Step "<b>%s</b>" signed off by %s.'
|
|
)) % (step.name, self.env.user.name))
|
|
return True
|
|
|
|
def _fp_check_step_inputs_complete(self):
|
|
"""Raise UserError if the step has REQUIRED step_input prompts
|
|
that haven't been recorded yet. AS9100 / Nadcap need a complete
|
|
per-step data trail; finishing a step with missing prompts breaks
|
|
the audit chain.
|
|
|
|
2026-05-24: also blocks orphaned steps (recipe_node_id NULL —
|
|
happens when the source recipe was deleted, e.g. a per-part clone
|
|
cleanup). Without a recipe link there's no way to verify required
|
|
prompts; defaulting to "let it through" was a silent compliance
|
|
gap. Managers can bypass via the same flag, audit chatter records
|
|
the override.
|
|
|
|
Manager bypass via context fp_skip_required_inputs_gate=True
|
|
(e.g. paper-form catch-up or documented customer deviation).
|
|
Bypasses are posted to chatter naming the user.
|
|
"""
|
|
if self.env.context.get('fp_skip_required_inputs_gate'):
|
|
for step in self:
|
|
step.job_id.message_post(body=Markup(_(
|
|
'Required-inputs gate bypassed on step "<b>%s</b>" by %s. '
|
|
'Documented deviation — review the step\'s prompts.'
|
|
)) % (step.name, self.env.user.name))
|
|
return
|
|
for step in self:
|
|
# Orphan-step block — NULL recipe_node means we can't list
|
|
# required prompts, so we conservatively refuse to finish.
|
|
if not step.recipe_node_id:
|
|
raise UserError(_(
|
|
'Step "%(step)s" cannot be finished — this step has '
|
|
'no recipe link (the source recipe was deleted or the '
|
|
'job was created before recipes were assigned). '
|
|
'Required-input verification is impossible without '
|
|
'the recipe. Escalate to a manager — they can bypass '
|
|
'with an audit-chatter entry.'
|
|
) % {'step': step.name})
|
|
missing = step._fp_missing_required_step_inputs()
|
|
if not missing:
|
|
continue
|
|
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
|
|
raise UserError(_(
|
|
'Step "%(step)s" cannot be finished — %(n)s required '
|
|
'input(s) not recorded yet: %(names)s. '
|
|
'Click "Record Inputs" on the step row to enter the '
|
|
'missing values, then finish. '
|
|
'Managers can override via context flag '
|
|
'fp_skip_required_inputs_gate=True for documented '
|
|
'deviations.'
|
|
) % {
|
|
'step': step.name,
|
|
'n': len(missing),
|
|
'names': names,
|
|
})
|
|
|
|
def _fp_open_input_wizard(self, advance_after=False):
|
|
"""Open the Record Inputs OWL dialog (Sub 12e v4).
|
|
|
|
Replaces the form-view-based wizard with a custom OWL Dialog
|
|
component (fp_record_inputs_dialog.js). The dialog renders
|
|
each prompt as a proper card with semantic HTML — no more
|
|
list-cell-as-card CSS hacks.
|
|
|
|
When advance_after is True, the dialog's Save button commits
|
|
values then dispatches the result of action_finish_and_advance
|
|
so the step finishes + auto-starts the next step in one flow.
|
|
"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'fp_record_inputs_dialog',
|
|
'params': {
|
|
'step_id': self.id,
|
|
'advance_after': bool(advance_after),
|
|
},
|
|
}
|
|
|
|
# NB: action_open_input_wizard is defined further down (line ~829)
|
|
# — that one stays as the per-row "Record" button entry-point.
|
|
# _fp_open_input_wizard above adds the advance_after pathway used
|
|
# only by action_finish_and_advance.
|
|
|
|
# NOTE — the earlier duplicate `button_finish` definition that held
|
|
# the duration-overrun + bake.window auto-spawn logic has been merged
|
|
# into the canonical button_finish further down (line ~1130). Python
|
|
# was silently keeping only the LAST definition in this class body,
|
|
# so the bake.window auto-spawn was dead code for the entire WO-30051
|
|
# era. Don't re-introduce a second button_finish here.
|
|
|
|
# ==================================================================
|
|
# 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:
|
|
# sudo() — technicians lack sale.order ACL (Rule 13m).
|
|
job = step.sudo().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:
|
|
# sudo() — technicians lack sale.order ACL (Rule 13m).
|
|
job = step.sudo().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()
|
|
# sudo() — technicians lack sale.order ACL (Rule 13m).
|
|
for so_line in self.sudo().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 _fp_check_receiving_gate(self):
|
|
"""Block step transitions until parts are physically received.
|
|
|
|
Applied to every step EXCEPT Contract Review (paperwork — doesn't
|
|
need parts on the floor). Fires from both button_start and
|
|
button_finish so an operator can't begin OR complete physical
|
|
work before the receiving record is closed.
|
|
|
|
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
|
|
pattern as the qty / QC / bake gates. Audit trail is preserved
|
|
via the state-transition tracking on chatter.
|
|
|
|
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
|
|
that's the terminal state (inspection moved into the recipe's
|
|
racking step; ``'inspected'`` was dropped in the 2026-05-18
|
|
cleanup).
|
|
"""
|
|
if self.env.context.get('fp_skip_receiving_gate'):
|
|
return
|
|
for step in self:
|
|
if step._fp_is_contract_review_step():
|
|
continue
|
|
# sudo() — technicians don't have sale.order ACL but the
|
|
# gate's purpose is checking a denormalized state field.
|
|
# Rule 13m: cross-module reads in tablet/floor controllers
|
|
# must sudo() the source recordset.
|
|
so = step.sudo().job_id.sale_order_id
|
|
if not so:
|
|
# Internal rework / no SO — gate doesn't apply.
|
|
continue
|
|
if 'x_fc_receiving_status' not in so._fields:
|
|
# Defensive: configurator module not installed.
|
|
continue
|
|
if so.x_fc_receiving_status != 'received':
|
|
label = dict(
|
|
so._fields['x_fc_receiving_status'].selection
|
|
).get(
|
|
so.x_fc_receiving_status,
|
|
so.x_fc_receiving_status or 'unknown',
|
|
)
|
|
raise UserError(_(
|
|
'Step "%(step)s" cannot proceed — parts not received '
|
|
'yet (SO %(so)s receiving status: %(status)s).\n\n'
|
|
'Close the receiving record (Sales > %(so)s > '
|
|
'Receiving) before starting or finishing work on '
|
|
'this step. A manager can bypass this gate for '
|
|
'documented exceptions.'
|
|
) % {
|
|
'step': step.name,
|
|
'so': so.name or '?',
|
|
'status': label,
|
|
})
|
|
|
|
def button_start(self):
|
|
"""Single source of truth for step start:
|
|
1. Sub 13 predecessor gate (raise UserError if blocking)
|
|
2. Receiving gate (raise UserError if parts not received)
|
|
3. Policy B Contract Review auto-open (route to QA-005)
|
|
4. Sub 8 Racking auto-open (route to racking inspection)
|
|
5. super().button_start() + serial promotion for the standard
|
|
path
|
|
|
|
Manager bypasses available via context:
|
|
fp_skip_predecessor_check=True skips the Sub 13 gate
|
|
fp_skip_receiving_gate=True skips the receiving gate
|
|
"""
|
|
# ---- 1. Sub 13 predecessor gate ----------------------------------
|
|
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
|
for step in self:
|
|
if skip_pred:
|
|
continue
|
|
if not step._fp_should_block_predecessors():
|
|
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' cannot start until earlier steps are "
|
|
"finished, skipped, or cancelled.\n\nBlocking step(s):\n %s\n\n"
|
|
"Options:\n"
|
|
" * Finish/Skip the blocking step(s) first.\n"
|
|
" * If this step legitimately runs in parallel, ask "
|
|
"a manager to flag it as Parallel Start on the recipe.\n"
|
|
" * Manager override via context fp_skip_predecessor_check=True."
|
|
) % (
|
|
step.name,
|
|
'\n '.join(
|
|
f'#{s.sequence} {s.name} ({s.state})'
|
|
for s in blocking[:5]
|
|
),
|
|
))
|
|
|
|
# ---- 2. Receiving gate -------------------------------------------
|
|
# Hard block (replaces the prior soft chatter warning). The
|
|
# helper exempts Contract Review steps internally, so contract
|
|
# review can still auto-open below regardless of receiving state.
|
|
self._fp_check_receiving_gate()
|
|
|
|
# ---- 3. Policy B Contract Review auto-open -----------------------
|
|
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
|
|
|
|
# ---- 4. Sub 8 Racking auto-open ----------------------------------
|
|
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
|
|
|
|
# ---- 5. Standard path: start + serial promote --------------------
|
|
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):
|
|
"""Canonical button_finish — gates first, then super(), then
|
|
post-finish side effects.
|
|
|
|
Gates (raise UserError, blocking finish):
|
|
- Required step_input prompts recorded (S21 / WO-30051 fix).
|
|
Manager bypass: fp_skip_required_inputs_gate=True.
|
|
- Sign-off recorded when recipe step has requires_signoff=True
|
|
(S22 / F1 audit fix). Auto-sign captures the finisher when
|
|
no supervisor has pre-signed. Manager bypass:
|
|
fp_skip_signoff_gate=True.
|
|
- Contract Review (QA-005) complete when customer requires it.
|
|
- Receiving gate — parts physically on site for this WO.
|
|
(Racking-inspection gate removed — racking is a recipe step
|
|
now, not a separate workflow. _fp_check_racking_inspection_
|
|
complete() is kept as a helper for diagnostics.)
|
|
|
|
Post-finish (idempotent, never blocks):
|
|
- Promote attached serials from in_process -> inspected on the
|
|
terminal step of the job.
|
|
- Chatter warning when duration_actual >= 1.5x duration_expected.
|
|
- Auto-spawn a bake.window for wet plating steps on recipes
|
|
flagged requires_bake_relief.
|
|
"""
|
|
# ----- Gates ----------------------------------------------------
|
|
# Order matters: cheapest checks first. Required-inputs is a pure
|
|
# ORM query; contract review and receiving may touch related models.
|
|
self._fp_check_step_inputs_complete()
|
|
# Sign-off: auto-capture the finisher's uid first (no-op when a
|
|
# supervisor pre-signed via action_signoff), THEN gate. Gate only
|
|
# fires when both autosign and explicit sign-off skipped (e.g.
|
|
# migration scripts, background crons).
|
|
self._fp_autosign_if_required()
|
|
self._fp_check_signoff_complete()
|
|
self._fp_check_contract_review_complete()
|
|
self._fp_check_receiving_gate()
|
|
|
|
# ----- Post-shop gate (spec 2026-05-25 D12) ---------------------
|
|
# When finishing the LAST open step on an in_progress job, run
|
|
# the bake/qty/QC gates that used to live in button_mark_done.
|
|
# Failure raises UserError on THIS click — operator fixes
|
|
# (qty, bake, QC) and retries the finish. Without this the
|
|
# auto-advance helper would silently fail with no error path.
|
|
for step in self:
|
|
if step.state not in ('in_progress', 'paused', 'ready'):
|
|
continue
|
|
job = step.job_id
|
|
if not job or job.state != 'in_progress':
|
|
continue
|
|
# Would this finish leave every step terminal?
|
|
siblings_open = job.step_ids.filtered(
|
|
lambda s: s.id != step.id
|
|
and s.state not in ('done', 'skipped', 'cancelled')
|
|
)
|
|
if siblings_open:
|
|
continue # not the last open step — skip the gates
|
|
job._fp_check_finish_gates()
|
|
|
|
result = super().button_finish()
|
|
|
|
# ----- Post-shop auto-advance (spec 2026-05-25) -----------------
|
|
# After super().button_finish flips step state to done, ask the
|
|
# job to check whether ALL steps are now terminal. If so the
|
|
# helper auto-advances state to awaiting_cert / awaiting_ship.
|
|
for job in self.mapped('job_id'):
|
|
job._fp_check_advance_post_shop()
|
|
|
|
# ----- Post-finish side effects --------------------------------
|
|
BW = self.env['fusion.plating.bake.window']
|
|
Bath = self.env['fusion.plating.bath']
|
|
for step in self:
|
|
if step.state != 'done':
|
|
continue
|
|
step._fp_promote_serials_on_finish()
|
|
# 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))
|
|
# Bake-window auto-spawn — wet plating step + recipe flagged
|
|
# requires_bake_relief. Heuristic identifies the actual
|
|
# plate-out step (kind=wet OR "plating" as a word in name),
|
|
# excluding inspection/bake/mask/rack steps that mention
|
|
# plating in passing (e.g. "Post-plate Inspection").
|
|
recipe_root = step.job_id.recipe_id
|
|
if not recipe_root:
|
|
continue
|
|
requires = getattr(recipe_root, 'requires_bake_relief', False)
|
|
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
|
|
if not requires or not window_hrs:
|
|
continue
|
|
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
|
|
existing = BW.sudo().search([
|
|
('part_ref', '=', step.job_id.name),
|
|
('lot_ref', '=', f'step-{step.id}'),
|
|
], limit=1)
|
|
if existing:
|
|
continue
|
|
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
|
|
|
|
# ==================================================================
|
|
# 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 Record Inputs OWL dialog from the per-row Record
|
|
button on the job form.
|
|
|
|
Contract-review steps redirect to the QA-005 form (same gate as
|
|
action_finish_and_advance) so the per-row Record button stays
|
|
consistent with Finish & Next.
|
|
"""
|
|
self.ensure_one()
|
|
cr_action = self._fp_contract_review_redirect()
|
|
if cr_action:
|
|
return cr_action
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'fp_record_inputs_dialog',
|
|
'params': {
|
|
'step_id': self.id,
|
|
'advance_after': False,
|
|
},
|
|
}
|
|
|
|
def action_mark_gating_passed(self):
|
|
"""1-click pass for gating steps (kind=='gating').
|
|
|
|
Performs button_start (or button_resume if paused) followed by
|
|
button_finish in the same transaction. Posts a chatter audit on
|
|
the parent job naming the user.
|
|
|
|
Only valid for kind='gating' steps in state in (ready, pending,
|
|
paused). NOOPs on already-terminal steps for idempotency. Raises
|
|
UserError if called on a non-gating step (defensive — UI dispatcher
|
|
only renders Mark Passed for gating kinds).
|
|
|
|
Bypasses the S21 required-inputs gate (gating steps have no
|
|
required inputs by design — they're admin gates).
|
|
|
|
Spec: 2026-05-24-workspace-step-actions-design.md Change 5.
|
|
"""
|
|
for step in self:
|
|
if step.state in ('done', 'skipped', 'cancelled'):
|
|
continue
|
|
kind_code = (
|
|
step.recipe_node_id.kind_id.code
|
|
if step.recipe_node_id and step.recipe_node_id.kind_id
|
|
else None
|
|
)
|
|
if kind_code != 'gating':
|
|
raise UserError(_(
|
|
"Mark Passed is only valid for gating steps. "
|
|
"This step's kind is %s."
|
|
) % (kind_code or 'unknown'))
|
|
if step.state not in ('ready', 'pending', 'paused'):
|
|
continue
|
|
if step.state == 'paused':
|
|
step.button_resume()
|
|
if step.state != 'in_progress':
|
|
step.button_start()
|
|
step.with_context(
|
|
fp_skip_required_inputs_gate=True,
|
|
).button_finish()
|
|
step.job_id.message_post(body=_(
|
|
'Gate "%(name)s" marked passed by %(user)s.'
|
|
) % {'name': step.name, 'user': self.env.user.name})
|
|
return True
|
|
|
|
def _fp_contract_review_redirect(self):
|
|
"""Return an ir.actions.act_window opening the part's QA-005
|
|
Contract Review form, or False to indicate "no redirect needed".
|
|
|
|
Triggers when:
|
|
* the recipe node is flagged default_kind='contract_review', AND
|
|
* the linked part has no review yet OR the review is still in
|
|
a non-terminal state (draft / assistant_review / manager_review).
|
|
|
|
Once the review reaches state 'complete' or 'dismissed' the step
|
|
is allowed to finish through the normal path, which is how the
|
|
operator clears the contract-review gate after signing QA-005.
|
|
|
|
Soft-fail: if the job has no part_catalog_id we cannot route to
|
|
a per-part review, so we fall through to the standard wizard
|
|
rather than blocking the operator.
|
|
"""
|
|
self.ensure_one()
|
|
node = self.recipe_node_id
|
|
if not node or node.default_kind != 'contract_review':
|
|
return False
|
|
part = self.job_id.part_catalog_id
|
|
if not part:
|
|
_logger.warning(
|
|
"Contract-review step '%s' on job %s has no part_catalog_id "
|
|
"— cannot redirect to QA-005 form, falling through to "
|
|
"standard wizard.",
|
|
self.name, self.job_id.name,
|
|
)
|
|
return False
|
|
review = part.x_fc_contract_review_id
|
|
if review and review.state in ('complete', 'dismissed'):
|
|
return False
|
|
return part.action_start_contract_review()
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Sub 12d — Step Details Quick-Look modal
|
|
# ------------------------------------------------------------------
|
|
# Three computed/related fields that power the read-only manager
|
|
# quick-look modal. The modal is bound via context= on the parent
|
|
# job form's <field name="step_ids"/> — no TransientModel needed.
|
|
|
|
# Job-level context for the quick-look modal — restored after commit
|
|
# b0070afc accidentally removed these while still referencing them in
|
|
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
|
|
# the 2026-05-22 Phase 1-4 deploy).
|
|
quick_look_partner_id = fields.Many2one(
|
|
'res.partner',
|
|
string='Customer',
|
|
related='job_id.partner_id',
|
|
readonly=True,
|
|
)
|
|
quick_look_part_catalog_id = fields.Many2one(
|
|
'fp.part.catalog',
|
|
string='Part',
|
|
related='job_id.part_catalog_id',
|
|
readonly=True,
|
|
)
|
|
quick_look_qty = fields.Float(
|
|
string='Order Qty',
|
|
related='job_id.qty',
|
|
readonly=True,
|
|
)
|
|
quick_look_instruction_attachment_ids = fields.Many2many(
|
|
'ir.attachment',
|
|
string='Instruction Images',
|
|
related='recipe_node_id.instruction_attachment_ids',
|
|
readonly=True,
|
|
)
|
|
quick_look_instructions = fields.Html(
|
|
string='Operator Instructions',
|
|
related='recipe_node_id.description',
|
|
readonly=True,
|
|
)
|
|
quick_look_collect_master = fields.Boolean(
|
|
string='Collect Measurements',
|
|
related='recipe_node_id.collect_measurements',
|
|
readonly=True,
|
|
)
|
|
quick_look_prompt_ids = fields.Many2many(
|
|
'fusion.plating.process.node.input',
|
|
string='Prompts',
|
|
compute='_compute_quick_look_prompt_ids',
|
|
)
|
|
quick_look_recorded_value_ids = fields.Many2many(
|
|
'fp.job.step.move.input.value',
|
|
string='Recorded Values',
|
|
compute='_compute_quick_look_recorded_value_ids',
|
|
)
|
|
|
|
@api.depends('recipe_node_id', 'recipe_node_id.input_ids')
|
|
def _compute_quick_look_prompt_ids(self):
|
|
for step in self:
|
|
node = step.recipe_node_id
|
|
if node:
|
|
step.quick_look_prompt_ids = node.input_ids.filtered(
|
|
lambda i: (i.kind or 'step_input') == 'step_input'
|
|
).sorted('sequence')
|
|
else:
|
|
step.quick_look_prompt_ids = False
|
|
|
|
def _compute_quick_look_recorded_value_ids(self):
|
|
Value = self.env['fp.job.step.move.input.value']
|
|
for step in self:
|
|
if not step.id:
|
|
step.quick_look_recorded_value_ids = False
|
|
continue
|
|
step.quick_look_recorded_value_ids = Value.search([
|
|
('move_id.from_step_id', '=', step.id),
|
|
], order='create_date desc')
|
|
|
|
def action_open_full_form(self):
|
|
"""From the quick-look modal, escape to the full editable form."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fp.job.step',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
'name': self.name,
|
|
}
|
|
|
|
def action_open_quick_look(self):
|
|
"""Open the read-only Step Details quick-look modal.
|
|
|
|
Bound to the row-button on the embedded step list — explicit
|
|
trigger needed because editable="bottom" intercepts row clicks
|
|
for inline editing rather than opening the form view.
|
|
"""
|
|
self.ensure_one()
|
|
view = self.env.ref(
|
|
'fusion_plating_jobs.view_fp_job_step_quick_look_form'
|
|
)
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fp.job.step',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'view_id': view.id,
|
|
'views': [(view.id, 'form')],
|
|
'target': 'new',
|
|
'name': self.name,
|
|
}
|
|
|
|
|
|
def _fp_record_one_piece_auto_move(self):
|
|
"""Decide whether to silently record a move before the step
|
|
finishes. Six cases:
|
|
- qty_at_step == 0: nothing to do (parts already moved).
|
|
- last runnable step: parts complete in place; no move.
|
|
- SEED-ONLY qty + downstream: bulk-move all parts to next
|
|
step in one move. Paperwork / first steps don't physically
|
|
hold parts per-piece.
|
|
- real qty == 1 + downstream: record move(1).
|
|
- real qty > 1 + downstream: raise — operator must use
|
|
Complete 1 → Next (streaming) or Move… (batched).
|
|
- real qty > 1 + last step: allow (qty_done auto-tick Phase 2).
|
|
Called from action_finish_and_advance just before button_finish.
|
|
"""
|
|
self.ensure_one()
|
|
qty = self.qty_at_step
|
|
if qty <= 0:
|
|
return False
|
|
next_step = self.job_id.step_ids.filtered(
|
|
lambda s: s.sequence > self.sequence
|
|
and s.state in ('pending', 'ready')
|
|
).sorted('sequence')[:1]
|
|
if not next_step:
|
|
return False
|
|
# Bulk-move all parts in one shot. Covers three use cases:
|
|
# - seed-only qty (paperwork / first step): creates the
|
|
# first explicit move record for the batch
|
|
# - real incoming, qty == 1 (streaming flow last part):
|
|
# same as Complete 1 → Next's tail call
|
|
# - real incoming, qty > 1 (batched flow): one click moves
|
|
# everything forward — operators with small parts don't
|
|
# have to click Complete 1 → Next repeatedly
|
|
# Complete 1 → Next is still available for one-by-one flow.
|
|
self.env['fp.job.step.move'].create({
|
|
'job_id': self.job_id.id,
|
|
'from_step_id': self.id,
|
|
'to_step_id': next_step.id,
|
|
'transfer_type': 'step',
|
|
'qty_moved': qty,
|
|
'moved_by_user_id': self.env.user.id,
|
|
})
|
|
return True
|