diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index f7183e9f..1af0f261 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 565cddb0..07f3b597 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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 diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 5b817167..6829e899 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -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', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py index f5c84881..78e37f0a 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py @@ -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 # ====================================================================== diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js new file mode 100644 index 00000000..076e23bd --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js @@ -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; + } + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js index 06521134..01b9d9c8 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -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" }); } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss index 3cdac437..a94a0f8e 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss @@ -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; +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml new file mode 100644 index 00000000..e03c3432 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml @@ -0,0 +1,72 @@ + + + + + +
+ +
+ + +
+ + + +
+ This step has no recipe link (the source + recipe was deleted or the job was created before recipes + were assigned). Required-input verification can't happen + without the recipe. +
+
+ + Escalate to a manager — they can bypass with an + audit-chatter entry. +
+
+ + + +
+ required input(s) + haven't been recorded yet: +
+
    + +
  • + + +
  • +
    +
+
+ + + +
+ + +
+ + + + + +
+
+ +