feat(jobs): step qty gate + partial-qty + display rename
Three coupled shop-floor corrections: - fp.job._compute_display_name: renders "Work Order # 00011" in form header, breadcrumbs, M2O dropdowns, and error messages. DB name stays as WH/JOB/00011 - existing chatter/cert/delivery references unchanged. - fp.job.step.button_finish: refuses if qty_at_step > 0 AND a downstream pending/ready step exists. Last runnable step is exempt (parts complete in place). Manager bypass via fp_skip_qty_gate=True context key. - fp.job.step.action_complete_one_to_next: new per-row button "Complete 1 -> Next" for streaming flow (large parts going one-by-one). Records move(qty=1) to next step; if drain takes qty_at_step to 0, auto-finishes source + auto-starts destination via existing action_finish_and_advance. - fp.job.step._fp_record_one_piece_auto_move: auto-move shim wired into action_finish_and_advance. qty=1 + downstream => silently record move(1). qty>1 + downstream => raise pointing at Complete 1 -> Next. Last step always allowed. - 16 new TestQtyGate tests covering gate / shim / auto-finish / last-step exemption / display rename / Move wizard zero-qty. Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.13.13',
|
||||
'version': '19.0.18.15.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -66,6 +66,22 @@ class FpJob(models.Model):
|
||||
default=lambda self: _('New'),
|
||||
index=True,
|
||||
)
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_display_name(self):
|
||||
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
|
||||
human-facing surface (form header, breadcrumbs, M2O dropdowns,
|
||||
smart-button titles, error messages). The DB `name` is
|
||||
unchanged so existing certs / deliveries / chatter references
|
||||
don't break.
|
||||
"""
|
||||
for job in self:
|
||||
if job.name and '/' in job.name:
|
||||
suffix = job.name.rsplit('/', 1)[-1]
|
||||
job.display_name = _('Work Order # %s') % suffix
|
||||
else:
|
||||
job.display_name = job.name or ''
|
||||
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# cancelled
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
@@ -109,10 +109,28 @@ class FpJobStep(models.Model):
|
||||
default='um',
|
||||
)
|
||||
dwell_time_minutes = fields.Float()
|
||||
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
|
||||
# Label intentionally has no unit suffix — the unit follows the
|
||||
# company's `x_fc_default_temp_uom` setting and is surfaced via the
|
||||
# adjacent `bake_setpoint_temp_uom_display` compute. Hardcoding °C
|
||||
# in the label was the most visible "Celsius leaks everywhere"
|
||||
# offender flagged 2026-05-10.
|
||||
bake_setpoint_temp = fields.Float(string='Bake Setpoint')
|
||||
bake_setpoint_temp_uom_display = fields.Char(
|
||||
string='Unit',
|
||||
compute='_compute_bake_setpoint_temp_uom_display',
|
||||
help='Temperature unit pulled live from Settings → Fusion Plating → '
|
||||
'Units of Measure. Updates everywhere the moment the admin '
|
||||
'flips Fahrenheit ↔ Celsius.',
|
||||
)
|
||||
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
|
||||
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
|
||||
|
||||
@api.depends_context('company')
|
||||
def _compute_bake_setpoint_temp_uom_display(self):
|
||||
sym = '°F' if (self.env.company.x_fc_default_temp_uom or 'F') == 'F' else '°C'
|
||||
for rec in self:
|
||||
rec.bake_setpoint_temp_uom_display = sym
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recipe-related (Task 1.6)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -365,11 +383,28 @@ class FpJobStep(models.Model):
|
||||
return True
|
||||
|
||||
def button_finish(self):
|
||||
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
|
||||
for step in self:
|
||||
if step.state != 'in_progress':
|
||||
raise UserError(_(
|
||||
"Step '%s' is in state '%s' — only in-progress steps can finish."
|
||||
) % (step.name, step.state))
|
||||
# Quantity gate: refuses if parts still parked AND there's
|
||||
# a downstream step to move them into. Last runnable step
|
||||
# is exempt — parts finishing there complete in place
|
||||
# (qty_done reconciliation at job close is the catch-net).
|
||||
if not skip_qty_gate and step.qty_at_step > 0:
|
||||
has_downstream = step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence > step.sequence
|
||||
and s.state in ('pending', 'ready')
|
||||
)
|
||||
if has_downstream:
|
||||
raise UserError(_(
|
||||
"Step '%(name)s' still has %(n)d part(s) "
|
||||
"parked — move them to the next step before "
|
||||
"finishing. Use the row's 'Complete 1 → Next' "
|
||||
"or 'Move…' button."
|
||||
) % {'name': step.name, 'n': step.qty_at_step})
|
||||
now = fields.Datetime.now()
|
||||
# Close the open timelog (the one with no date_finished)
|
||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||
@@ -382,3 +417,138 @@ class FpJobStep(models.Model):
|
||||
# Sum of all interval durations becomes duration_actual
|
||||
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
return True
|
||||
|
||||
|
||||
# ===== Manager-only overrides ===========================================
|
||||
# Used when an operator skipped or cancelled a step in error, or when
|
||||
# the actual shop-floor work happened outside the ERP and the manager
|
||||
# needs to retroactively mark the step complete. Both actions are
|
||||
# group-gated and post a clear audit entry to the step's chatter.
|
||||
|
||||
def button_manager_force_complete(self):
|
||||
"""Force any non-done state straight to 'done'. Stamps the first-
|
||||
start / first-finish audit fields if blank so the timeline isn't
|
||||
broken, and closes any timelog still left open."""
|
||||
if not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'):
|
||||
raise AccessError(_(
|
||||
'Only Plating Manager+ can force-complete a step.'
|
||||
))
|
||||
for step in self:
|
||||
if step.state == 'done':
|
||||
raise UserError(_(
|
||||
"Step '%s' is already done."
|
||||
) % step.name)
|
||||
prev_state = step.state
|
||||
now = fields.Datetime.now()
|
||||
# Close any open timelogs first — labour already incurred
|
||||
# stays in the audit even when we shortcut to done.
|
||||
open_log = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished
|
||||
)
|
||||
if open_log:
|
||||
open_log.write({'date_finished': now, 'state': 'stopped'})
|
||||
vals = {'state': 'done'}
|
||||
if not step.date_started:
|
||||
vals['date_started'] = now
|
||||
vals['started_by_user_id'] = self.env.user.id
|
||||
if not step.date_finished:
|
||||
vals['date_finished'] = now
|
||||
vals['finished_by_user_id'] = self.env.user.id
|
||||
step.write(vals)
|
||||
step.message_post(body=_(
|
||||
'Step force-completed by %s (was %s).'
|
||||
) % (self.env.user.name, prev_state))
|
||||
return True
|
||||
|
||||
def button_manager_reset_to_ready(self):
|
||||
"""Reset any non-ready step back to 'ready' so the operator can
|
||||
run it normally. Audited via chatter.
|
||||
|
||||
Side-effects, depending on the previous state:
|
||||
- in_progress / paused → close any open timelog (mirrors
|
||||
button_cancel) so labour already logged stays in the audit.
|
||||
- done → also clear date_finished + finished_by_user_id so the
|
||||
next button_finish writes fresh first-finish stamps instead
|
||||
of preserving stale ones.
|
||||
|
||||
date_started + started_by_user_id are preserved across resets —
|
||||
they record the first start ever (audit), and duration_actual is
|
||||
computed from the sum of timelogs, not (finish - start), so the
|
||||
elapsed math remains correct."""
|
||||
if not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'):
|
||||
raise AccessError(_(
|
||||
'Only Plating Manager+ can reset a step state.'
|
||||
))
|
||||
now = fields.Datetime.now()
|
||||
for step in self:
|
||||
if step.state == 'ready':
|
||||
raise UserError(_(
|
||||
"Step '%s' is already in Ready state."
|
||||
) % step.name)
|
||||
prev_state = step.state
|
||||
vals = {'state': 'ready'}
|
||||
|
||||
# Close any still-open timelog (defensive — usually only
|
||||
# in_progress/paused will have one).
|
||||
open_log = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished
|
||||
)
|
||||
if open_log:
|
||||
open_log.write({'date_finished': now, 'state': 'stopped'})
|
||||
|
||||
# If the step had been completed, wipe the finish stamps so
|
||||
# the next Finish records fresh audit values. Skip this for
|
||||
# in_progress / paused / skipped / cancelled / pending — they
|
||||
# either have no finish stamp or shouldn't have one cleared.
|
||||
if step.state == 'done':
|
||||
vals['date_finished'] = False
|
||||
vals['finished_by_user_id'] = False
|
||||
|
||||
step.write(vals)
|
||||
step.message_post(body=_(
|
||||
'Step state reset to Ready by %s (was %s).'
|
||||
) % (self.env.user.name, prev_state))
|
||||
return True
|
||||
|
||||
|
||||
def action_complete_one_to_next(self):
|
||||
"""One-piece flow shortcut: records move(qty=1) from this
|
||||
step to the next pending/ready step, drains qty_at_step by 1.
|
||||
If the drain takes qty_at_step to 0, auto-finishes the source
|
||||
and starts the destination step (via action_finish_and_advance).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
raise UserError(_(
|
||||
"Step '%s' must be in progress to complete a part."
|
||||
) % self.name)
|
||||
if self.qty_at_step < 1:
|
||||
raise UserError(_(
|
||||
"No parts parked at step '%s' — nothing to complete."
|
||||
) % self.name)
|
||||
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:
|
||||
raise UserError(_(
|
||||
"Step '%s' is the last runnable step on the job — "
|
||||
"no downstream step to move into. Finish the step "
|
||||
"instead (it will close out the job)."
|
||||
) % self.name)
|
||||
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': 1,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
# qty_at_step is computed from moves; force re-read before
|
||||
# checking whether this was the last part.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step == 0:
|
||||
return self.action_finish_and_advance()
|
||||
return True
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
<h1><field name="display_name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
|
||||
Reference in New Issue
Block a user