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

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.27.0',
'version': '19.0.10.28.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -797,6 +797,13 @@ class FpJobStep(models.Model):
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.
@@ -809,6 +816,17 @@ class FpJobStep(models.Model):
)) % (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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.33.1.7',
'version': '19.0.33.1.8',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -121,6 +121,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# readability.
'fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml',
'fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js',
# ---- Finish block dialog (required-inputs gate + manager bypass) ----
'fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml',
'fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js',
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',

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
# ======================================================================

View File

@@ -0,0 +1,62 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpFinishBlockDialog
//
// Shown when /fp/workspace/finish_step returns ok=false with gate='required_inputs'.
// Non-managers see: Cancel + Record Inputs.
// Managers see: Cancel + Record Inputs + ⚠ Bypass & Finish (audit).
//
// On Record Inputs → caller opens the Record Inputs dialog for the step.
// On Bypass → caller calls finish_step again with bypass_required_inputs=true.
//
// Spec: workspace step actions follow-up 2026-05-24.
// =============================================================================
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class FpFinishBlockDialog extends Component {
static template = "fusion_plating_shopfloor.FpFinishBlockDialog";
static components = { Dialog };
static props = {
close: Function,
stepName: String,
// Server-classified gate. 'required_inputs' is the only one the
// current Record/Bypass UI handles — other gates fall back to
// showing the message + a Cancel button only.
gate: String,
message: String,
missingPrompts: { type: Array, optional: true },
orphanStep: { type: Boolean, optional: true },
canBypass: { type: Boolean, optional: true },
// Callbacks
onRecordInputs: { type: Function, optional: true },
onBypass: { type: Function, optional: true },
};
setup() {
this.state = useState({ bypassing: false });
}
onCancel() { this.props.close(); }
async onRecord() {
this.props.close();
if (this.props.onRecordInputs) await this.props.onRecordInputs();
}
async onBypassClick() {
if (!window.confirm(
`Bypass required inputs and finish "${this.props.stepName}"?\n\n` +
`This will be logged to the job chatter naming you as the ` +
`bypassing manager. Use only for documented deviations.`
)) return;
this.state.bypassing = true;
try {
if (this.props.onBypass) await this.props.onBypass();
this.props.close();
} finally {
this.state.bypassing = false;
}
}
}

View File

@@ -29,11 +29,12 @@ import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog";
import { FpDamageDialog } from "./fp_damage_dialog";
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog };
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
setup() {
this.notification = useService("notification");
@@ -317,19 +318,50 @@ export class FpJobWorkspace extends Component {
});
return;
}
// Plain finish — no signature required
// Plain finish — route through /fp/workspace/finish_step which
// returns structured errors so we can show the FpFinishBlockDialog
// for required-inputs failures (with manager bypass option).
await this._callFinishStep(step, /* bypass */ false);
}
async _callFinishStep(step, bypassRequiredInputs) {
try {
const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true,
const res = await rpc("/fp/workspace/finish_step", {
step_id: step.id,
bypass_required_inputs: !!bypassRequiredInputs,
});
if (res && res.ok) {
this.notification.add("Step finished.", { type: "success" });
this.notification.add(
bypassRequiredInputs
? "Step finished (required-inputs bypassed; audit logged)."
: "Step finished.",
{ type: "success" },
);
await this.refresh();
} else {
this.notification.add((res && res.error) || "Finish failed", { type: "danger" });
return;
}
// Structured-error branch — show block dialog for the
// required_inputs gate (it has the rich Record / Bypass UX).
// Other gates fall back to a plain notification.
if (res && res.gate === "required_inputs") {
this.dialog.add(FpFinishBlockDialog, {
stepName: step.name,
gate: res.gate,
message: res.error || "Cannot finish step.",
missingPrompts: res.missing_prompts || [],
orphanStep: !!res.orphan_step,
canBypass: !!res.bypass_available,
onRecordInputs: async () => this.onRecordInputs(step),
onBypass: async () => this._callFinishStep(step, true),
});
return;
}
this.notification.add(
(res && res.error) || "Finish failed",
{ type: "danger" },
);
} catch (err) {
this.notification.add(err.message, { type: "danger" });
this.notification.add(err.message || String(err), { type: "danger" });
}
}

View File

@@ -663,3 +663,47 @@ $_ws-text-hex: #1d1d1f;
0%, 100% { opacity: 1.0; }
50% { opacity: 0.6; }
}
// =============================================================================
// FINISH BLOCK DIALOG (required-inputs gate, manager bypass)
// =============================================================================
.o_fp_finish_block .modal-body { padding: 1.5rem; }
.o_fp_finish_block_body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.o_fp_finish_block_step {
font-size: 1.1rem;
color: #b45309;
background: #fef3c7;
padding: 0.7rem 1rem;
border-radius: 6px;
border-left: 4px solid #f59e0b;
}
.o_fp_finish_block_msg {
color: var(--text-secondary, #333);
}
.o_fp_finish_block_list {
margin: 0;
padding: 0 0 0 1rem;
list-style: none;
li {
padding: 0.3rem 0;
font-weight: 600;
}
}
.o_fp_finish_block_action_note {
color: var(--text-secondary, #555);
font-style: italic;
padding: 0.6rem 0.8rem;
background: #f3f4f6;
border-radius: 4px;
}

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.FpFinishBlockDialog">
<Dialog title="'Cannot Finish Step'" size="'md'" contentClass="'o_fp_finish_block'">
<div class="o_fp_finish_block_body">
<div class="o_fp_finish_block_step">
<i class="fa fa-exclamation-circle me-2"/>
<strong t-esc="props.stepName"/>
</div>
<!-- Orphan step (NULL recipe link) — different copy -->
<t t-if="props.orphanStep">
<div class="o_fp_finish_block_msg">
This step has <strong>no recipe link</strong> (the source
recipe was deleted or the job was created before recipes
were assigned). Required-input verification can't happen
without the recipe.
</div>
<div class="o_fp_finish_block_action_note">
<i class="fa fa-user-md me-1"/>
Escalate to a manager — they can bypass with an
audit-chatter entry.
</div>
</t>
<!-- Standard missing-prompts case -->
<t t-elif="props.missingPrompts and props.missingPrompts.length">
<div class="o_fp_finish_block_msg">
<t t-esc="props.missingPrompts.length"/> required input(s)
haven't been recorded yet:
</div>
<ul class="o_fp_finish_block_list">
<t t-foreach="props.missingPrompts" t-as="p" t-key="p.id">
<li>
<i class="fa fa-square-o me-1"/>
<t t-esc="p.name"/>
</li>
</t>
</ul>
</t>
<!-- Fallback: server returned something else -->
<t t-else="">
<div class="o_fp_finish_block_msg" t-esc="props.message"/>
</t>
</div>
<t t-set-slot="footer">
<button class="btn btn-light"
t-on-click="() => this.onCancel()"
t-att-disabled="state.bypassing">Cancel</button>
<button t-if="!props.orphanStep and props.onRecordInputs"
class="btn btn-primary"
t-on-click="() => this.onRecord()"
t-att-disabled="state.bypassing">
<i class="fa fa-pencil me-1"/> Record Inputs
</button>
<button t-if="props.canBypass"
class="btn btn-danger"
t-on-click="() => this.onBypassClick()"
t-att-disabled="state.bypassing">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-if="state.bypassing">Bypassing…</t>
<t t-else="">Bypass &amp; Finish (Audit)</t>
</button>
</t>
</Dialog>
</t>
</templates>