feat(workspace): per-kind step action buttons in Job Workspace
Fix: in the Job Workspace tablet view, the Start button was buried inside a parent t-if that required the step to already be in_progress or blocked. So ready/paused steps showed no buttons at all - operators couldn't advance the WO from this screen (the reason the user couldn't complete anything on WO-30057). Template restructure (job_workspace.xml): - Always-visible line 1 (icon + step# + name + ACTIVE/PAUSED badge + meta) - Non-terminal detail panel (chips + instructions + opt-out + GateViz) visible on every non-done step so operator reads ahead - Action row dispatched per-kind via getStepActions() helper Per-kind action dispatcher (job_workspace.js): - in_progress -> Record Inputs, Pause, Finish (or Finish & Sign Off) - paused -> Resume, Record Inputs, Finish - contract_review (ready) -> Open QA-005 Form - gating (ready) -> Mark Passed (1-click start+finish) - requires_rack_assignment -> Start (Assign Rack) - opens FpRackPartsDialog - else (ready) -> Start 5 new handlers: onPauseStep / onResumeStep / onMarkPassed / onOpenContractReview / onStartWithRack. Pause and Resume use ORM RPC (button_pause/button_resume) since no HTTP endpoint exists. New model method (fp.job.step.action_mark_gating_passed): - 1-click pass for gating steps - does button_start + button_finish in one transaction, posts chatter "Gate X marked passed by Y" - Raises UserError if called on a non-gating step (defensive) - Bypasses S21 required-inputs gate (gating steps have no inputs) Controller: workspace_controller.py adds requires_rack_assignment to the step payload so the JS dispatcher can route correctly. Spec: docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md Sub-B (Record Inputs tablet polish: inputmode/prefill/date pickers/ signature pad/camera) is brainstormed but deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
# Job Workspace — Per-Kind Step Actions
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Modules:** `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||
**Status:** Approved, awaiting implementation plan.
|
||||
**Sub-project:** A of 2. Sub-B (Record Inputs tablet polish — `inputmode`, prefill,
|
||||
date/time pickers, signature pad, camera) is brainstormed but DEFERRED.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Operator opens WO-30057 in the Job Workspace tablet view. Step 1 (Contract Review)
|
||||
shows ✓ (auto-completed from prior QA-005). Steps 2-12 each show only a bare
|
||||
`○ Step N <name>` row — **no Start button, no action of any kind**. The operator
|
||||
has no way to advance the job from this screen, even though every step is
|
||||
`state='ready'` and `can_start=True` on the backend.
|
||||
|
||||
### Root cause
|
||||
|
||||
In [`job_workspace.xml:105`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml),
|
||||
the expanded step-detail block is gated:
|
||||
|
||||
```xml
|
||||
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||
<div class="o_fp_ws_step_detail">
|
||||
...
|
||||
<!-- Start button is INSIDE this parent gate -->
|
||||
<div t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button t-on-click="() => this.onStartStep(step.id)">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
```
|
||||
|
||||
`isStepActive` returns true only when `step.state === 'in_progress'`. For a
|
||||
`state='ready'` step with no blocker, the parent `<t t-if>` is false — the whole
|
||||
detail block (incl. the inner Start button) never renders. **Dead code.**
|
||||
|
||||
### Secondary gaps
|
||||
|
||||
Even if Start were reachable, certain step kinds need different actions, not a
|
||||
generic Start/Finish chain:
|
||||
|
||||
| Kind | Today (broken) | What it actually needs |
|
||||
|---|---|---|
|
||||
| `contract_review` | Hidden Start button | **Open QA-005 Form** button (uses existing `_fp_open_contract_review`) |
|
||||
| `gating` | Hidden Start, then operator clicks Finish too | **1-click "Mark Passed"** (no work to do — it's an admin gate) |
|
||||
| `requires_rack_assignment=True` | Hidden Start | Start should open the **Rack Parts** dialog first |
|
||||
| `state='paused'` | Hidden Start | Should show **Resume** + Finish + Record Inputs |
|
||||
| all kinds, `state='in_progress'` | Shows Finish, Record Inputs | Missing a **Pause** button |
|
||||
|
||||
### Operator can't see what's coming
|
||||
|
||||
The recipe-author info (thickness target, dwell time, bake temp, sign-off
|
||||
required) currently only renders on the active step. Operators can't read ahead
|
||||
to know what they're about to start. CLAUDE.md S20 "Tablet usability pass" called
|
||||
this out for the per-step kanban; the same gap exists in the Job Workspace.
|
||||
|
||||
---
|
||||
|
||||
## Approved fix
|
||||
|
||||
### Change 1 — Template restructure
|
||||
|
||||
Replace the parent-gated detail block in [`job_workspace.xml:88-170`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml)
|
||||
with three independent rendering layers per step:
|
||||
|
||||
```xml
|
||||
<div t-att-class="...">
|
||||
<!-- [ALWAYS] Line 1: icon + step# + name + meta -->
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
|
||||
<span class="o_fp_ws_step_meta">...assignee, duration...</span>
|
||||
</div>
|
||||
|
||||
<!-- [NON-TERMINAL] Read-ahead detail: chips + instructions + GateViz -->
|
||||
<div class="o_fp_ws_step_detail"
|
||||
t-if="step.state not in ('done', 'skipped', 'cancelled')">
|
||||
<!-- chips (thickness/dwell/bake/signoff) -->
|
||||
<div class="o_fp_ws_step_chips">
|
||||
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">🎯 Thickness ...</span>
|
||||
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">⏱ Dwell ...</span>
|
||||
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">🔥 Bake ...°</span>
|
||||
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">✎ Sign-off</span>
|
||||
</div>
|
||||
|
||||
<!-- recipe instructions -->
|
||||
<div t-if="step.instructions" class="o_fp_ws_step_instr"><t t-esc="step.instructions"/></div>
|
||||
|
||||
<!-- opt-out -->
|
||||
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
||||
<i class="fa fa-ban"/> Skipped per recipe override
|
||||
</div>
|
||||
|
||||
<!-- blocker viz -->
|
||||
<GateViz t-if="step.blocker_kind !== 'none'"
|
||||
canStart="false"
|
||||
blockerKind="step.blocker_kind"
|
||||
blockerReason="step.blocker_reason"
|
||||
jumpTargetModel="step.blocker_jump_target_model"
|
||||
jumpTargetId="step.blocker_jump_target_id"
|
||||
onJump.bind="onJumpToBlocker"/>
|
||||
</div>
|
||||
|
||||
<!-- [ACTIONABLE] Action row — per-kind buttons per the dispatcher -->
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="!step.override_excluded
|
||||
and step.blocker_kind === 'none'
|
||||
and step.state not in ('done', 'skipped', 'cancelled')">
|
||||
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
|
||||
<button t-att-class="action.cssClass"
|
||||
t-on-click="() => this.dispatchStepAction(step, action.key)">
|
||||
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Keep the existing `isStepActive(step)` helper for the ACTIVE badge but **don't**
|
||||
let it gate the detail block.
|
||||
|
||||
### Change 2 — `getStepActions(step)` per-kind dispatcher
|
||||
|
||||
New JS helper in [`job_workspace.js`](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js).
|
||||
Returns an array of action descriptors based on `step.state` + `step.kind` +
|
||||
`step.requires_rack_assignment` + `step.requires_signoff`:
|
||||
|
||||
```js
|
||||
getStepActions(step) {
|
||||
// Done/skipped/cancelled → no actions (caller already hides)
|
||||
if (['done', 'skipped', 'cancelled'].includes(step.state)) return [];
|
||||
// Blocked → no actions (caller already shows GateViz)
|
||||
if (step.blocker_kind && step.blocker_kind !== 'none') return [];
|
||||
if (step.override_excluded) return [];
|
||||
|
||||
const actions = [];
|
||||
if (step.state === 'in_progress') {
|
||||
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
|
||||
actions.push({ key: 'pause', label: 'Pause', icon: 'fa fa-pause', cssClass: 'btn btn-light' });
|
||||
actions.push({
|
||||
key: 'finish',
|
||||
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
|
||||
icon: 'fa fa-check', cssClass: 'btn btn-success'
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
if (step.state === 'paused') {
|
||||
actions.push({ key: 'resume', label: 'Resume', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
|
||||
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
|
||||
actions.push({
|
||||
key: 'finish',
|
||||
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
|
||||
icon: 'fa fa-check', cssClass: 'btn btn-success'
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
// state in ('pending', 'ready') — entry-point per kind
|
||||
if (step.kind === 'contract_review') {
|
||||
actions.push({ key: 'open_contract_review', label: 'Open QA-005 Form',
|
||||
icon: 'fa fa-file-text-o', cssClass: 'btn btn-primary' });
|
||||
return actions;
|
||||
}
|
||||
if (step.kind === 'gating') {
|
||||
actions.push({ key: 'mark_passed', label: 'Mark Passed',
|
||||
icon: 'fa fa-check-circle', cssClass: 'btn btn-success' });
|
||||
return actions;
|
||||
}
|
||||
if (step.requires_rack_assignment) {
|
||||
actions.push({ key: 'start_with_rack', label: 'Start (Assign Rack)',
|
||||
icon: 'fa fa-server', cssClass: 'btn btn-primary' });
|
||||
return actions;
|
||||
}
|
||||
// Default
|
||||
actions.push({ key: 'start', label: 'Start', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
|
||||
return actions;
|
||||
}
|
||||
```
|
||||
|
||||
### Change 3 — `dispatchStepAction(step, key)`
|
||||
|
||||
Single router method that delegates to handler methods:
|
||||
|
||||
```js
|
||||
async dispatchStepAction(step, key) {
|
||||
switch (key) {
|
||||
case 'start': return this.onStartStep(step.id);
|
||||
case 'resume': return this.onResumeStep(step); // button_resume — distinct from button_start
|
||||
case 'pause': return this.onPauseStep(step);
|
||||
case 'record_inputs': return this.onRecordInputs(step);
|
||||
case 'finish': return this.onFinishStep(step);
|
||||
case 'mark_passed': return this.onMarkPassed(step);
|
||||
case 'open_contract_review': return this.onOpenContractReview(step);
|
||||
case 'start_with_rack': return this.onStartWithRack(step);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change 4 — New JS handlers
|
||||
|
||||
**`onPauseStep(step)`** — calls `fp.job.step.button_pause` via ORM RPC.
|
||||
(No `/fp/shopfloor/pause_wo` HTTP endpoint exists; the legacy stop_wo
|
||||
endpoint's docstring claims pause isn't implemented but `button_pause`
|
||||
does exist in `fusion_plating/models/fp_job_step.py:320`. Using ORM
|
||||
RPC sidesteps the need to add a new HTTP route.)
|
||||
|
||||
```js
|
||||
async onPauseStep(step) {
|
||||
const reason = window.prompt(`Pause reason for "${step.name}"?`, '');
|
||||
if (reason === null) return; // operator cancelled
|
||||
try {
|
||||
await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: 'button_pause',
|
||||
args: [[step.id]],
|
||||
kwargs: { reason: reason || 'no reason given' },
|
||||
});
|
||||
this.notification.add('Step paused.', { type: 'success' });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onResumeStep(step)`** — calls `fp.job.step.button_resume` via ORM RPC.
|
||||
Distinct from `onStartStep` because the model has separate methods:
|
||||
`button_start` is for state=ready → in_progress; `button_resume` is for
|
||||
state=paused → in_progress (preserves accrued time + reason audit).
|
||||
|
||||
```js
|
||||
async onResumeStep(step) {
|
||||
try {
|
||||
await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: 'button_resume',
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add('Step resumed.', { type: 'success' });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onMarkPassed(step)`** — calls a new ORM method `action_mark_gating_passed`
|
||||
which does `button_start` + `button_finish` in one server call:
|
||||
|
||||
```js
|
||||
async onMarkPassed(step) {
|
||||
try {
|
||||
await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: 'action_mark_gating_passed',
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add('Gate marked passed.', { type: 'success' });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onOpenContractReview(step)`** — calls the existing `_fp_open_contract_review`
|
||||
helper on `fp.job.step` (per CLAUDE.md Policy B section). Returns an act_window
|
||||
that the action service opens. After dialog close, refresh:
|
||||
|
||||
```js
|
||||
async onOpenContractReview(step) {
|
||||
try {
|
||||
const result = await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: '_fp_open_contract_review',
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
if (result) {
|
||||
await this.action.doAction(result, { onClose: () => this.refresh() });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Couldn't open QA-005", { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onStartWithRack(step)`** — opens the existing Rack Parts dialog from
|
||||
`move_controller.py`. On commit (rack assigned + parts loaded), calls
|
||||
`onStartStep(step.id)`. Implementation reuses `FpRackPartsDialog` from
|
||||
`fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js`:
|
||||
|
||||
```js
|
||||
async onStartWithRack(step) {
|
||||
this.dialog.add(FpRackPartsDialog, {
|
||||
jobId: this.state.jobId,
|
||||
stepId: step.id,
|
||||
partRef: this.state.data.job.part_number || '',
|
||||
defaultQty: this.state.data.job.qty || 1,
|
||||
onCommitted: async () => {
|
||||
// Rack assigned → now start the step
|
||||
await this.onStartStep(step.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Change 5 — New backend method `action_mark_gating_passed`
|
||||
|
||||
In [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py),
|
||||
add:
|
||||
|
||||
```python
|
||||
def action_mark_gating_passed(self):
|
||||
"""1-click pass for gating steps (kind=='gating'). Performs
|
||||
button_start() then button_finish() in the same transaction.
|
||||
Posts chatter ("Gate marked passed by <user>") on the parent job.
|
||||
|
||||
Only valid for state in (ready, pending, paused) — defensive
|
||||
NOOP otherwise (idempotent on repeat clicks).
|
||||
"""
|
||||
for step in self:
|
||||
if step.state in ('done', 'skipped', 'cancelled'):
|
||||
continue
|
||||
kind_code = step.recipe_node_id.kind_id.code if (
|
||||
step.recipe_node_id and step.recipe_node_id.kind_id
|
||||
) else None
|
||||
if kind_code != 'gating':
|
||||
raise UserError(_(
|
||||
"action_mark_gating_passed is only valid for gating "
|
||||
"steps (this step has kind=%s).") % (kind_code or 'unknown'))
|
||||
if step.state not in ('ready', 'pending', 'paused'):
|
||||
continue
|
||||
# Resume if paused, then start, then finish — bypass the input
|
||||
# gate (gating steps have no required inputs by design).
|
||||
if step.state == 'paused':
|
||||
step.button_resume()
|
||||
if step.state != 'in_progress':
|
||||
step.button_start()
|
||||
step.with_context(
|
||||
fp_skip_required_inputs_gate=True,
|
||||
).button_finish()
|
||||
step.job_id.message_post(body=_(
|
||||
'Gate "%(name)s" marked passed by %(user)s.'
|
||||
) % {'name': step.name, 'user': self.env.user.name})
|
||||
return True
|
||||
```
|
||||
|
||||
### Change 6 — Verify controller payload has `requires_rack_assignment`
|
||||
|
||||
The workspace controller payload at
|
||||
[`workspace_controller.py:75-95`](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)
|
||||
already includes `kind`, `state`, `can_start`, `requires_signoff`,
|
||||
`blocker_kind`. Verify `requires_rack_assignment` is included; if not, add it:
|
||||
|
||||
```python
|
||||
'requires_rack_assignment': bool(getattr(step, 'requires_rack_assignment', False)),
|
||||
```
|
||||
|
||||
### Change 7 — Version bumps
|
||||
|
||||
| Module | From | To |
|
||||
|---|---|---|
|
||||
| `fusion_plating_jobs` | `19.0.10.26.0` | `19.0.10.27.0` (new `action_mark_gating_passed` method) |
|
||||
| `fusion_plating_shopfloor` | `19.0.33.1.4` | `19.0.33.1.5` (JS + XML restructure + controller payload) |
|
||||
|
||||
No data migration needed — purely behavioural / UX.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### Manual smoke (after deploy)
|
||||
|
||||
1. Open WO-30057 in Job Workspace.
|
||||
2. Confirm Step 1 (Contract Review, done) shows ✓ + name, NO buttons.
|
||||
3. Confirm Step 2 (Masking, ready) shows **Start** button.
|
||||
4. Click Start → confirm step transitions to `in_progress` → buttons swap to Record Inputs, Pause, Finish.
|
||||
5. Click Pause → confirm prompt → confirm step transitions to `paused` → buttons swap to Resume, Record Inputs, Finish.
|
||||
6. Click Resume → confirm back to `in_progress` + correct buttons.
|
||||
7. Click Finish → confirm step completes → next step (Incoming Inspection, ready) now shows Start.
|
||||
8. Locate a job with a Contract Review step that hasn't been auto-completed (rare — most parts have prior QA-005). Confirm **Open QA-005 Form** button. Click → form opens. Submit → refresh → step completes.
|
||||
9. Locate or create a job with a Gating step (kind='gating'). Confirm **✓ Mark Passed** button. Click → step jumps from ready to done in one click.
|
||||
10. Find a step where `requires_signoff=True`. Click Finish → signature pad opens (existing behaviour). Sign → step completes.
|
||||
11. Find a blocked step (predecessor not done). Confirm GateViz renders, NO action buttons.
|
||||
12. Find an opt-out step (`override_excluded=True`). Confirm "Skipped per recipe override" notice, NO action buttons.
|
||||
|
||||
### Smoke for chip / instructions visibility
|
||||
|
||||
13. On any in-flight job, confirm chips (🎯 thickness, ⏱ dwell, 🔥 bake, ✎ sign-off) + recipe instructions render on **every non-done step** (not just the active one). Operator can read ahead.
|
||||
|
||||
### Battle test followup
|
||||
|
||||
Defer to Sub B (no new automated test for this UX-only change — covered by manual smoke).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- **`inputmode` attributes / number keyboards / prefill / date/time pickers /
|
||||
signature pad in Record Inputs / camera capture** — all deferred to Sub B
|
||||
(record-inputs tablet polish).
|
||||
- **Auditing every kind's default input prompts** — deferred to Sub B. The
|
||||
existing dialog renders all 15 input_types; Sub B verifies each is good UX.
|
||||
- **Skip step button** — supervisor-only, accessible via backend form. Not
|
||||
adding to operator workspace.
|
||||
- **Reassign step** — supervisor-only.
|
||||
- **Per-recipe ordering or kind fixes** — already covered by recent recipe
|
||||
cleanup spec.
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` | Template restructure — always-visible action row + non-terminal detail block (Change 1) |
|
||||
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `getStepActions`, `dispatchStepAction`, `onPauseStep`, `onMarkPassed`, `onOpenContractReview`, `onStartWithRack` (Changes 2-4) |
|
||||
| `fusion_plating_shopfloor/static/src/scss/job_workspace.scss` | Minor styling for the action row (consistent spacing across button counts) |
|
||||
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | Add `requires_rack_assignment` to step payload if missing (Change 6) |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | Bump to `19.0.33.1.5` (Change 7) |
|
||||
| `fusion_plating_jobs/models/fp_job_step.py` | Add `action_mark_gating_passed()` method (Change 5) |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Bump to `19.0.10.27.0` (Change 7) |
|
||||
|
||||
Estimated diff: ~200 lines added, ~50 modified, ~10 deleted.
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.26.0',
|
||||
'version': '19.0.10.27.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -1397,6 +1397,50 @@ class FpJobStep(models.Model):
|
||||
},
|
||||
}
|
||||
|
||||
def action_mark_gating_passed(self):
|
||||
"""1-click pass for gating steps (kind=='gating').
|
||||
|
||||
Performs button_start (or button_resume if paused) followed by
|
||||
button_finish in the same transaction. Posts a chatter audit on
|
||||
the parent job naming the user.
|
||||
|
||||
Only valid for kind='gating' steps in state in (ready, pending,
|
||||
paused). NOOPs on already-terminal steps for idempotency. Raises
|
||||
UserError if called on a non-gating step (defensive — UI dispatcher
|
||||
only renders Mark Passed for gating kinds).
|
||||
|
||||
Bypasses the S21 required-inputs gate (gating steps have no
|
||||
required inputs by design — they're admin gates).
|
||||
|
||||
Spec: 2026-05-24-workspace-step-actions-design.md Change 5.
|
||||
"""
|
||||
for step in self:
|
||||
if step.state in ('done', 'skipped', 'cancelled'):
|
||||
continue
|
||||
kind_code = (
|
||||
step.recipe_node_id.kind_id.code
|
||||
if step.recipe_node_id and step.recipe_node_id.kind_id
|
||||
else None
|
||||
)
|
||||
if kind_code != 'gating':
|
||||
raise UserError(_(
|
||||
"Mark Passed is only valid for gating steps. "
|
||||
"This step's kind is %s."
|
||||
) % (kind_code or 'unknown'))
|
||||
if step.state not in ('ready', 'pending', 'paused'):
|
||||
continue
|
||||
if step.state == 'paused':
|
||||
step.button_resume()
|
||||
if step.state != 'in_progress':
|
||||
step.button_start()
|
||||
step.with_context(
|
||||
fp_skip_required_inputs_gate=True,
|
||||
).button_finish()
|
||||
step.job_id.message_post(body=_(
|
||||
'Gate "%(name)s" marked passed by %(user)s.'
|
||||
) % {'name': step.name, 'user': self.env.user.name})
|
||||
return True
|
||||
|
||||
def _fp_contract_review_redirect(self):
|
||||
"""Return an ir.actions.act_window opening the part's QA-005
|
||||
Contract Review form, or False to indicate "no redirect needed".
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.33.1.4',
|
||||
'version': '19.0.33.1.5',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -89,6 +89,9 @@ class FpWorkspaceController(http.Controller):
|
||||
'dwell_time_minutes': step.dwell_time_minutes or 0,
|
||||
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
|
||||
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
|
||||
'requires_rack_assignment': bool(getattr(
|
||||
step, 'requires_rack_assignment', False,
|
||||
)),
|
||||
'can_start': bool(step.can_start) if 'can_start' in step._fields else (
|
||||
step.state in ('ready', 'paused') and step.blocker_kind == 'none'
|
||||
),
|
||||
|
||||
@@ -27,11 +27,12 @@ import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -138,6 +139,79 @@ export class FpJobWorkspace extends Component {
|
||||
return step.state === "in_progress";
|
||||
}
|
||||
|
||||
// ---- Per-kind action dispatcher (Spec A 2026-05-24) -------------------
|
||||
// getStepActions returns the list of action descriptors to render for a
|
||||
// step based on its current state + kind. dispatchStepAction routes the
|
||||
// selected action key to the right handler. See spec
|
||||
// 2026-05-24-workspace-step-actions-design.md.
|
||||
|
||||
getStepActions(step) {
|
||||
// Terminal states render no action buttons (template also guards).
|
||||
if (["done", "skipped", "cancelled"].includes(step.state)) return [];
|
||||
// Blocked / opt-out steps show GateViz / notice, no actions.
|
||||
if (step.blocker_kind && step.blocker_kind !== "none") return [];
|
||||
if (step.override_excluded) return [];
|
||||
|
||||
const actions = [];
|
||||
if (step.state === "in_progress") {
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({ key: "pause", label: "Pause",
|
||||
icon: "fa fa-pause", cssClass: "btn btn-light" });
|
||||
actions.push({
|
||||
key: "finish",
|
||||
label: step.requires_signoff ? "Finish & Sign Off" : "Finish",
|
||||
icon: "fa fa-check", cssClass: "btn btn-success",
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
if (step.state === "paused") {
|
||||
actions.push({ key: "resume", label: "Resume",
|
||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({
|
||||
key: "finish",
|
||||
label: step.requires_signoff ? "Finish & Sign Off" : "Finish",
|
||||
icon: "fa fa-check", cssClass: "btn btn-success",
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
// state in ('pending', 'ready') — entry-point per kind.
|
||||
if (step.kind === "contract_review") {
|
||||
actions.push({ key: "open_contract_review", label: "Open QA-005 Form",
|
||||
icon: "fa fa-file-text-o", cssClass: "btn btn-primary" });
|
||||
return actions;
|
||||
}
|
||||
if (step.kind === "gating") {
|
||||
actions.push({ key: "mark_passed", label: "Mark Passed",
|
||||
icon: "fa fa-check-circle", cssClass: "btn btn-success" });
|
||||
return actions;
|
||||
}
|
||||
if (step.requires_rack_assignment) {
|
||||
actions.push({ key: "start_with_rack", label: "Start (Assign Rack)",
|
||||
icon: "fa fa-server", cssClass: "btn btn-primary" });
|
||||
return actions;
|
||||
}
|
||||
// Default — plain Start
|
||||
actions.push({ key: "start", label: "Start",
|
||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||
return actions;
|
||||
}
|
||||
|
||||
async dispatchStepAction(step, key) {
|
||||
switch (key) {
|
||||
case "start": return this.onStartStep(step.id);
|
||||
case "resume": return this.onResumeStep(step);
|
||||
case "pause": return this.onPauseStep(step);
|
||||
case "record_inputs": return this.onRecordInputs(step);
|
||||
case "finish": return this.onFinishStep(step);
|
||||
case "mark_passed": return this.onMarkPassed(step);
|
||||
case "open_contract_review": return this.onOpenContractReview(step);
|
||||
case "start_with_rack": return this.onStartWithRack(step);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step actions ------------------------------------------------------
|
||||
async onStartStep(stepId) {
|
||||
try {
|
||||
@@ -214,6 +288,78 @@ export class FpJobWorkspace extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- New per-kind handlers (Spec A 2026-05-24) ------------------------
|
||||
|
||||
async onPauseStep(step) {
|
||||
const reason = window.prompt(`Pause reason for "${step.name}"?`, "");
|
||||
if (reason === null) return; // operator cancelled
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "button_pause",
|
||||
args: [[step.id]],
|
||||
kwargs: { reason: reason || "no reason given" },
|
||||
});
|
||||
this.notification.add("Step paused.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Pause failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onResumeStep(step) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "button_resume",
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add("Step resumed.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Resume failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onMarkPassed(step) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "action_mark_gating_passed",
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add("Gate marked passed.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Mark passed failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onOpenContractReview(step) {
|
||||
try {
|
||||
const result = await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "_fp_open_contract_review",
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
if (result) {
|
||||
await this.action.doAction(result, { onClose: () => this.refresh() });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Couldn't open QA-005", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onStartWithRack(step) {
|
||||
const job = this.state.data.job;
|
||||
this.dialog.add(FpRackPartsDialog, {
|
||||
jobId: this.state.jobId,
|
||||
stepId: step.id,
|
||||
partRef: job.part_number || "",
|
||||
defaultQty: job.qty || 1,
|
||||
onCommitted: async () => {
|
||||
// Rack assigned → now start the step
|
||||
await this.onStartStep(step.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Action rail handlers ---------------------------------------------
|
||||
onCreateHold() {
|
||||
const job = this.state.data.job;
|
||||
|
||||
@@ -86,26 +86,31 @@
|
||||
|
||||
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
|
||||
<div t-att-class="'o_fp_ws_step ' + step.state +
|
||||
(isStepActive(step) ? ' active' : '') +
|
||||
(step.state === 'in_progress' ? ' active' : '') +
|
||||
(step.state === 'paused' ? ' paused' : '') +
|
||||
(step.override_excluded ? ' excluded' : '') +
|
||||
(step.blocker_kind !== 'none' ? ' blocked' : '')"
|
||||
t-att-data-step-id="step.id">
|
||||
|
||||
<!-- ALWAYS visible: line 1 (icon + step# + name + badges + meta) -->
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
|
||||
<span class="o_fp_ws_step_meta">
|
||||
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
|
||||
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||
<!-- NON-TERMINAL: read-ahead detail (chips + instructions + opt-out + GateViz) -->
|
||||
<t t-if="!['done', 'skipped', 'cancelled'].includes(step.state)">
|
||||
<div class="o_fp_ws_step_detail">
|
||||
<!-- Recipe chips (only on active step) -->
|
||||
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
|
||||
<!-- Recipe chips: visible on every non-done step so operator reads ahead -->
|
||||
<div class="o_fp_ws_step_chips"
|
||||
t-if="step.thickness_target or step.dwell_time_minutes or step.bake_setpoint_temp or step.requires_signoff or step.requires_rack_assignment">
|
||||
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
|
||||
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
|
||||
</span>
|
||||
@@ -118,10 +123,13 @@
|
||||
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
|
||||
✎ Sign-off required
|
||||
</span>
|
||||
<span t-if="step.requires_rack_assignment" class="o_fp_chip o_fp_chip_info">
|
||||
🔧 Rack assignment
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe author instructions (only on active step) -->
|
||||
<div t-if="step.instructions and isStepActive(step)"
|
||||
<!-- Recipe author instructions -->
|
||||
<div t-if="step.instructions"
|
||||
class="o_fp_ws_step_instr">
|
||||
<t t-esc="step.instructions"/>
|
||||
</div>
|
||||
@@ -140,30 +148,15 @@
|
||||
jumpTargetId="step.blocker_jump_target_id"
|
||||
onJump.bind="onJumpToBlocker"/>
|
||||
|
||||
<!-- Action buttons (only when unblocked) -->
|
||||
<!-- Action buttons: dispatched per-kind via getStepActions -->
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button class="btn btn-secondary me-2"
|
||||
t-on-click="() => this.onRecordInputs(step)">
|
||||
<i class="fa fa-pencil"/> Record Inputs
|
||||
</button>
|
||||
<button t-if="step.requires_signoff"
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish & Sign Off
|
||||
</button>
|
||||
<button t-else=""
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onStartStep(step.id)">
|
||||
<i class="fa fa-play"/> Start
|
||||
</button>
|
||||
t-if="!step.override_excluded and step.blocker_kind === 'none'">
|
||||
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
|
||||
<button t-att-class="action.cssClass + ' me-2'"
|
||||
t-on-click="() => this.dispatchStepAction(step, action.key)">
|
||||
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user