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:
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
# ======================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & Finish (Audit)</t>
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user