fix(workspace): required-inputs gate fires + manager bypass dialog

Two bugs:

1. Gate silently passed when step.recipe_node_id was NULL — happened
   to every WO-30057 step after this morning's clone delete (the FK
   ON DELETE SET NULL wiped the link). _fp_missing_required_step_inputs
   returned an empty recordset when node was None, so the gate had
   nothing to fail on and button_finish succeeded with zero audit.
   Fix: _fp_check_step_inputs_complete now treats NULL recipe_node_id
   as an explicit "no recipe link" hard block. Operator can't finish;
   manager bypass posts chatter audit.

2. No tablet UI for the manager bypass. The gate's bypass was a
   Python context flag — invisible from the JS layer, so managers
   were stuck behind the same hard error as operators.
   Fix: new /fp/workspace/finish_step endpoint returns structured
   errors (gate type, missing_prompts list, bypass_available bool).
   Server-side enforces manager group when bypass=True (can't trust
   the client). New FpFinishBlockDialog OWL modal renders:
   - Non-manager: Cancel + Record Inputs
   - Manager:     Cancel + Record Inputs + ⚠ Bypass & Finish (audit)

JobWorkspace.onFinishStep routes plain finishes through the new
endpoint; signature-required steps still go through /fp/workspace/sign_off
(separate gate). Added is_manager to /fp/workspace/load payload so
the JS knows which dialog variant to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 19:30:39 -04:00
parent 0371624afb
commit fc17754996
8 changed files with 319 additions and 10 deletions

View File

@@ -243,6 +243,13 @@ class FpWorkspaceController(http.Controller):
],
'required_certs': required_certs,
'receivings': receivings_payload,
# 2026-05-24 — is_manager surfaces to the JS so it can offer
# the manager-bypass affordance (e.g. on the required-inputs
# gate dialog). Server-side endpoints re-check the group
# before honouring any bypass param — this is for UI only.
'is_manager': env.user.has_group(
'fusion_plating.group_fusion_plating_manager',
),
}
# ======================================================================
@@ -310,6 +317,77 @@ class FpWorkspaceController(http.Controller):
'attachment_id': attachment_id,
}
# ======================================================================
# /fp/workspace/finish_step — structured-error finish with manager bypass
# ======================================================================
# Wraps step.button_finish with structured response shape so the
# frontend can render the FpFinishBlockDialog (Cancel / Record /
# Bypass) instead of a generic toast. Manager bypass requires the
# caller to set bypass_required_inputs=True AND the server confirms
# the user has the fusion_plating_manager group — trusting the
# context flag alone would let any operator bypass via raw RPC.
@http.route('/fp/workspace/finish_step', type='jsonrpc', auth='user')
def finish_step(self, step_id, bypass_required_inputs=False):
env = request.env
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found'}
# Server-side bypass authorization — can't trust the client.
is_manager = env.user.has_group(
'fusion_plating.group_fusion_plating_manager',
)
if bypass_required_inputs and not is_manager:
return {
'ok': False,
'error': 'Manager privilege required to bypass required inputs.',
'gate': 'permission_denied',
'bypass_available': False,
}
ctx = {}
if bypass_required_inputs:
ctx['fp_skip_required_inputs_gate'] = True
try:
step.with_context(**ctx).button_finish()
except UserError as e:
msg = str(e.args[0]) if e.args else str(e)
# Classify the gate so the JS knows whether to show the
# block dialog (required_inputs) vs a plain toast.
gate = 'other'
if 'required input' in msg.lower() or 'no recipe link' in msg.lower():
gate = 'required_inputs'
elif 'sign-off' in msg.lower() or 'sign off' in msg.lower():
gate = 'signoff'
elif 'predecessor' in msg.lower():
gate = 'predecessor'
elif 'contract review' in msg.lower():
gate = 'contract_review'
# Collect the per-prompt list when the gate is required_inputs
# AND the step has a recipe_node (orphan steps have nothing to list).
missing = []
if gate == 'required_inputs' and step.recipe_node_id:
try:
for p in step._fp_missing_required_step_inputs():
missing.append({'id': p.id, 'name': p.name or ''})
except Exception:
pass
return {
'ok': False,
'error': msg,
'gate': gate,
'missing_prompts': missing,
'orphan_step': not bool(step.recipe_node_id),
'bypass_available': is_manager and gate == 'required_inputs',
}
except Exception as exc:
_logger.exception("workspace/finish_step unexpected error")
return {'ok': False, 'error': str(exc), 'gate': 'unexpected'}
return {'ok': True}
# ======================================================================
# /fp/workspace/sign_off — capture signature + finish step atomically
# ======================================================================