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:
@@ -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