5-phase TDD plan with 28+ tasks executing the spec at docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md: - Phase 1: Workspace foundation — 5 shared OWL services (WorkflowChip, GateViz, SignaturePad, HoldComposer, KanbanCard), JobWorkspace OWL client action, workspace_controller with 4 endpoints, display_wo_name + blocker_* computes, smart button on fp.job form. - Phase 2: Auto-pause cron (fixes 411h ghost timer), late_risk_ratio + active_step_id computes, long_running flag on process node, ACL lift for operator (cert write, thickness create, override read). - Phase 3: Landing refactor — fp_shopfloor_landing replaces fp_shopfloor_tablet + folds in fp_plant_overview. Station-scoped kanban with All Plant toggle. - Phase 4: Manager dashboard refactor — 4 sibling tabs (Workflow Funnel, Approval Inbox, At-Risk, existing Plant Board), 3 new endpoints, bottleneck_score on fp.work.centre, 2 new KPI tiles. - Phase 5: Cleanup — remove deprecation stubs, retire plant_overview menu, update CLAUDE.md + README. Each phase ships independently; each task is a self-contained TDD cycle (write test → fail → implement → pass → commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 KiB
Shop Floor Tablet Redesign Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the outdated fp_shopfloor_tablet and fp_plant_overview with a unified Shop Floor Landing + Job Workspace + refactored Manager Dashboard, surfacing every feature added to fp.job / fp.job.step / fp.certificate since the original tablet was built.
Architecture: Three OWL client actions (fp_shopfloor_landing, fp_job_workspace, fp_manager_dashboard) + five shared OWL services (WorkflowChip, GateViz, SignaturePad, HoldComposer, KanbanCard) + new backend computes/cron/ACLs. Per spec 2026-05-22-shopfloor-tablet-redesign-design.md, built in 5 sequential phases, each independently deployable.
Tech Stack: Odoo 19, Python (controllers, models, crons), OWL 2.x (static template, static props = ["*"], standalone rpc()), SCSS with $o-webclient-color-scheme branch for dark mode, Odoo dialog service for modals.
Spec reference: docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md
Local dev:
- Container:
odoo-modsdev-app, db:modsdev(NOTodoo-dev-app/fusion-dev— that's stale CLAUDE.md) - Update:
docker exec odoo-modsdev-app odoo -d modsdev -u <module> --stop-after-init - Test:
docker exec odoo-modsdev-app odoo -d modsdev -i <module> --test-tags <tag> --stop-after-init - URL: http://localhost:8069
Module version bumps (apply with each phase's commits)
| Module | Current | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
|---|---|---|---|---|---|---|
fusion_plating |
19.0.20.6.2 | — | 19.0.20.7.0 (long_running on process.node, bottleneck_score on work.centre) |
— | 19.0.20.8.0 | — |
fusion_plating_jobs |
19.0.10.18.0 | 19.0.10.19.0 (display_wo_name, blocker_*, active_step_id) |
19.0.10.20.0 (cron + late_risk) | — | — | — |
fusion_plating_shopfloor |
19.0.26.2.0 | 19.0.27.0.0 (workspace + 5 services) | 19.0.27.1.0 (ACL lift) | 19.0.28.0.0 (Landing) | 19.0.29.0.0 (Manager refactor) | 19.0.29.1.0 |
fusion_plating_certificates |
(check) | — | 19.0.x+1 (ACL write on cert) | — | — | — |
File structure
NEW files
fusion_plating_shopfloor/
controllers/
landing_controller.py [Phase 3]
workspace_controller.py [Phase 1]
static/src/js/components/
workflow_chip.js [Phase 1]
gate_viz.js [Phase 1]
signature_pad.js [Phase 1]
hold_composer.js [Phase 1]
kanban_card.js [Phase 1]
static/src/xml/components/
workflow_chip.xml [Phase 1]
gate_viz.xml [Phase 1]
signature_pad.xml [Phase 1]
hold_composer.xml [Phase 1]
kanban_card.xml [Phase 1]
static/src/scss/components/
_workflow_chip.scss [Phase 1]
_gate_viz.scss [Phase 1]
_signature_pad.scss [Phase 1]
_hold_composer.scss [Phase 1]
_kanban_card.scss [Phase 1]
static/src/js/
job_workspace.js [Phase 1]
shopfloor_landing.js [Phase 3]
static/src/xml/
job_workspace.xml [Phase 1]
shopfloor_landing.xml [Phase 3]
static/src/scss/
job_workspace.scss [Phase 1]
shopfloor_landing.scss [Phase 3]
tests/
__init__.py [Phase 1]
test_workspace_load.py [Phase 1]
test_workspace_actions.py [Phase 1]
test_landing_kanban.py [Phase 3]
test_manager_funnel.py [Phase 4]
test_manager_inbox.py [Phase 4]
test_manager_at_risk.py [Phase 4]
fusion_plating_jobs/tests/
test_display_wo_name.py [Phase 1]
test_blocker_compute.py [Phase 1]
test_active_step_id.py [Phase 2]
test_late_risk_ratio.py [Phase 2]
test_autopause_cron.py [Phase 2]
MODIFIED files
fusion_plating_jobs/
__manifest__.py [P1, P2 version bumps]
models/fp_job.py [P1: display_wo_name | P2: late_risk_ratio, active_step_id]
models/fp_job_step.py [P1: blocker_kind/reason/jump_target | P2: _cron_autopause_stale_steps]
views/fp_job_form_inherit.xml [P1: smart-button "Open Job Workspace"]
data/fp_cron_data.xml [P2: add ir_cron_autopause_stale_steps]
fusion_plating/
__manifest__.py [P2, P4 bumps]
models/fp_process_node.py [P2: long_running Boolean]
models/fp_work_centre.py [P4: bottleneck_score, avg_wait_minutes]
views/ [P2: long_running toggle on process_node form]
fusion_plating_shopfloor/
__manifest__.py [P1, P2, P3, P4 bumps + asset registrations]
controllers/__init__.py [P1: + workspace_controller | P3: + landing_controller]
controllers/shopfloor_controller.py [P3: stub tablet_overview, plant_overview, drop queue | P5: remove stubs]
controllers/manager_controller.py [P4: add /fp/manager/funnel, approval_inbox, at_risk]
static/src/js/manager_dashboard.js [P4: tab refactor + 3 new tab components]
static/src/xml/manager_dashboard.xml [P4: tab nav + 4 tab templates]
static/src/scss/manager_dashboard.scss [P4: tab styles]
views/fp_menu.xml [P3: redirect old action, hide PlantOverview]
security/ir.model.access.csv [P2: append fp.job.node.override read]
fusion_plating_certificates/
__manifest__.py [P2 bump]
security/ir.model.access.csv [P2: extend operator perm_write on fp.certificate; perm_write+create on fp.thickness.reading]
Phase 1 — Workspace Foundation
Goal: Ship fp_job_workspace as a standalone client action accessible from a smart button on the existing fp.job form. Plus the 5 shared OWL services. Phase 1 must be functional on its own — a user can open any WO and use the Workspace before Landing exists.
Phase 1 deliverables:
fp.job.display_wo_namecomputefp.job.step.blocker_kind,blocker_reason,blocker_jump_target_model,blocker_jump_target_idcomputes- 5 shared OWL services with tests
workspace_controller.pywith 4 endpointsJobWorkspaceOWL component- Smart button on
fp.jobform opening the Workspace - Version bumps + manifest asset registration
Task 1.1 — Add display_wo_name compute on fp.job
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/models/fp_job.py -
Create:
fusion_plating/fusion_plating_jobs/tests/test_display_wo_name.py -
Step 1: Write the failing test
Create fusion_plating/fusion_plating_jobs/tests/test_display_wo_name.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestDisplayWoName(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
self.product = self.env['product.product'].create({'name': 'Test Prod'})
def _make_job(self, name):
return self.env['fp.job'].create({
'name': name,
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_wh_job_prefix_formatted(self):
job = self._make_job('WH/JOB/00001')
self.assertEqual(job.display_wo_name, 'WO # 00001')
def test_wh_job_with_year(self):
job = self._make_job('WH/JOB/2026/00042')
self.assertEqual(job.display_wo_name, 'WO # 00042')
def test_plain_numeric(self):
job = self._make_job('00123')
self.assertEqual(job.display_wo_name, 'WO # 00123')
def test_falsy_name(self):
# New record before save → name is False; computed returns empty
job = self.env['fp.job'].new({'name': False})
self.assertEqual(job.display_wo_name, '')
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -i fusion_plating_jobs --test-tags fp_jobs --stop-after-init
Expected: FAIL with AttributeError: 'fp.job' object has no attribute 'display_wo_name'.
- Step 3: Implement the compute on
fp.job
In fusion_plating/fusion_plating_jobs/models/fp_job.py, add inside the FpJob(models.Model) class (after the existing _inherit line and other fields, before any methods):
# Display formatter — "WO # 00001" used everywhere on tablet/dashboard.
# The underlying `name` field stays untouched (WH/JOB/00001) so reports,
# emails, and back-office forms continue using their canonical name.
# System-wide sequence rename is a separate decision (see spec §6.5).
display_wo_name = fields.Char(
compute='_compute_display_wo_name',
string='WO #',
help='Tablet/dashboard formatter — "WO # 00001" derived from name. '
'Underlying name field is unchanged.',
)
@api.depends('name')
def _compute_display_wo_name(self):
for job in self:
raw = (job.name or '').strip()
if not raw:
job.display_wo_name = ''
continue
# Take the last "/"-separated segment as the number portion.
# WH/JOB/00001 → 00001 ; WH/JOB/2026/00042 → 00042 ; 00123 → 00123
tail = raw.rsplit('/', 1)[-1]
job.display_wo_name = f'WO # {tail}'
(The @api.depends import is already present at the top of the file.)
- Step 4: Verify test passes
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
Expected: 4 tests pass.
- Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_display_wo_name.py
git commit -m "feat(fusion_plating_jobs): fp.job.display_wo_name compute (WO # 00001)"
Task 1.2 — Add blocker_kind / blocker_reason / blocker_jump_target_* on fp.job.step
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/models/fp_job_step.py -
Create:
fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py -
Step 1: Write the failing test
Create fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestBlockerCompute(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
self.product = self.env['product.product'].create({'name': 'Test Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/T1',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def _make_step(self, name, sequence, state='ready'):
return self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': name,
'sequence': sequence,
'state': state,
})
def test_step_not_blocked_returns_none(self):
step = self._make_step('Solo', 10, state='ready')
# No predecessor, no other gates → blocker_kind = 'none'
self.assertEqual(step.blocker_kind, 'none')
self.assertEqual(step.blocker_reason, '')
def test_predecessor_open_blocks(self):
s1 = self._make_step('Earlier', 10, state='in_progress')
s2 = self._make_step('Later', 20, state='ready')
# Trigger recompute
s2.invalidate_recordset(['blocker_kind', 'blocker_reason'])
if s2.requires_predecessor_done:
self.assertEqual(s2.blocker_kind, 'predecessor')
self.assertIn('Earlier', s2.blocker_reason or '')
self.assertEqual(s2.blocker_jump_target_model, 'fp.job.step')
self.assertEqual(s2.blocker_jump_target_id, s1.id)
def test_done_step_has_no_blocker(self):
step = self._make_step('Done', 10, state='done')
self.assertEqual(step.blocker_kind, 'none')
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
Expected: FAIL — 'fp.job.step' object has no attribute 'blocker_kind'.
- Step 3: Implement the 4 computes on
fp.job.step
In fusion_plating/fusion_plating_jobs/models/fp_job_step.py, add to the FpJobStep class (after existing fields):
# Gate visualizer — drives the OWL GateViz component. Returns the kind
# of blocker preventing this step from starting, a human reason, and an
# optional (model, id) target the operator can tap to jump to.
blocker_kind = fields.Selection(
[
('none', 'Not blocked'),
('predecessor', 'Waiting on predecessor'),
('contract_review', 'Contract review pending'),
('parts_not_received', 'Parts not received'),
('racking_required', 'Racking inspection required'),
('manager_input', 'Manager input required'),
('other', 'Other'),
],
compute='_compute_blocker',
string='Blocker Kind',
)
blocker_reason = fields.Char(
compute='_compute_blocker',
string='Blocker Reason',
help='Human-readable explanation surfaced in the GateViz block.',
)
blocker_jump_target_model = fields.Char(compute='_compute_blocker')
blocker_jump_target_id = fields.Integer(compute='_compute_blocker')
@api.depends(
'state', 'sequence', 'requires_predecessor_done',
'job_id.step_ids.state', 'job_id.step_ids.sequence',
)
def _compute_blocker(self):
for step in self:
# Steps in a terminal state are never "blocked"
if step.state in ('done', 'skipped', 'cancelled', 'in_progress'):
step.blocker_kind = 'none'
step.blocker_reason = ''
step.blocker_jump_target_model = False
step.blocker_jump_target_id = 0
continue
# Predecessor check — earlier-sequence step still open
if getattr(step, 'requires_predecessor_done', False):
earlier_open = step.job_id.step_ids.filtered(lambda x: (
x.id != step.id
and x.sequence < step.sequence
and x.state not in ('done', 'skipped', 'cancelled')
))
if earlier_open:
first_blocker = earlier_open.sorted('sequence')[0]
step.blocker_kind = 'predecessor'
step.blocker_reason = f'Waiting on Step {first_blocker.sequence // 10}: {first_blocker.name}'
step.blocker_jump_target_model = 'fp.job.step'
step.blocker_jump_target_id = first_blocker.id
continue
# Contract review / parts / racking gates — these are recognised
# by existing code paths but expressed here as the canonical
# source of truth for the tablet. Extend with explicit checks
# as those gate models mature.
step.blocker_kind = 'none'
step.blocker_reason = ''
step.blocker_jump_target_model = False
step.blocker_jump_target_id = 0
(The @api.depends import is already present at the top of the file.)
- Step 4: Verify test passes
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
Expected: 3 tests pass.
- Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job_step.py \
fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py
git commit -m "feat(fusion_plating_jobs): fp.job.step blocker_kind/reason/jump_target computes"
Task 1.3 — Bootstrap shopfloor tests/ directory + WorkflowChip component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/tests/__init__.py(empty) -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/workflow_chip.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py(add new assets) -
Step 1: Create tests/init.py (empty file, enables future Python tests)
Create fusion_plating/fusion_plating_shopfloor/tests/__init__.py as an empty file.
- Step 2: Create WorkflowChip OWL component JS
Create fusion_plating/fusion_plating_shopfloor/static/src/js/components/workflow_chip.js:
/** @odoo-module **/
// =============================================================================
// Fusion Plating — WorkflowChip (shared OWL service)
// Renders an fp.job.workflow.state as a colored chip + optional next-action.
// =============================================================================
import { Component } from "@odoo/owl";
export class WorkflowChip extends Component {
static template = "fusion_plating_shopfloor.WorkflowChip";
static props = {
state: { type: Object, optional: false }, // {id, name, color}
nextActionLabel: { type: String, optional: true },
};
get toneClass() {
// Map workflow.state.color (Selection: grey/blue/cyan/yellow/orange/
// green/success/danger/purple) to a Bootstrap-friendly suffix.
const map = {
grey: "muted", blue: "info", cyan: "info",
yellow: "warning", orange: "warning",
green: "success", success: "success",
danger: "danger", purple: "info",
};
return map[this.props.state.color] || "muted";
}
}
- Step 3: Create WorkflowChip template XML
Create fusion_plating/fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml:
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.WorkflowChip">
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + toneClass">
<span class="o_fp_wf_dot"/>
<span class="o_fp_wf_label" t-esc="props.state.name"/>
<t t-if="props.nextActionLabel">
<span class="o_fp_wf_next">· next: <t t-esc="props.nextActionLabel"/></span>
</t>
</span>
</t>
</templates>
- Step 4: Create WorkflowChip SCSS
Create fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss:
// =============================================================================
// WorkflowChip — colored milestone pill
// Dark-mode aware via $o-webclient-color-scheme branch (registered in BOTH
// web.assets_backend AND web.assets_web_dark — see manifest).
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_wf-bg-muted-hex: #f0f0f2;
$_wf-bg-info-hex: rgba(0, 113, 227, 0.15);
$_wf-bg-success-hex: rgba(52, 199, 89, 0.15);
$_wf-bg-warning-hex: rgba(255, 159, 10, 0.15);
$_wf-bg-danger-hex: rgba(255, 59, 48, 0.15);
@if $o-webclient-color-scheme == dark {
$_wf-bg-muted-hex: #2d2d2f !global;
}
.o_fp_wf_chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.o_fp_wf_dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.o_fp_wf_next {
font-weight: 400;
opacity: 0.75;
margin-left: 0.15rem;
}
.o_fp_wf_chip_muted { background: $_wf-bg-muted-hex; color: #666; }
.o_fp_wf_chip_info { background: $_wf-bg-info-hex; color: #0050a0; }
.o_fp_wf_chip_success { background: $_wf-bg-success-hex; color: #1d6e2f; }
.o_fp_wf_chip_warning { background: $_wf-bg-warning-hex; color: #b06600; }
.o_fp_wf_chip_danger { background: $_wf-bg-danger-hex; color: #b00018; }
@if $o-webclient-color-scheme == dark {
.o_fp_wf_chip_muted { color: #a8a8b0; }
.o_fp_wf_chip_info { color: #6cb6ff; }
.o_fp_wf_chip_success { color: #6be398; }
.o_fp_wf_chip_warning { color: #ffb84d; }
.o_fp_wf_chip_danger { color: #ff7a72; }
}
- Step 5: Register the 3 new assets in the manifest
In fusion_plating/fusion_plating_shopfloor/__manifest__.py, bump the version and add the new assets. Bump:
'version': '19.0.27.0.0',
Add to assets['web.assets_backend'] AFTER the tokens line and BEFORE other SCSS (so component tokens are available to consumers). The full insertion (find the existing 'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss', line and add immediately after):
# ---- Shared OWL services (Phase 1) ----
'fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss',
'fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml',
'fusion_plating_shopfloor/static/src/js/components/workflow_chip.js',
Mirror the same 3 lines into assets['web.assets_web_dark'] (after its tokens line) so dark mode picks up the SCSS branch.
- Step 6: Update + verify install
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
Expected: no errors. Check container log for Module fusion_plating_shopfloor: loaded line.
- Step 7: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/tests/__init__.py \
fusion_plating/fusion_plating_shopfloor/static/src/js/components/workflow_chip.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): WorkflowChip shared OWL service + dark-mode SCSS"
Task 1.4 — GateViz shared OWL component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/gate_viz.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py(add 3 new asset lines) -
Step 1: Create GateViz JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — GateViz (shared OWL service)
// "Can't start because…" explainer. Drives off step.blocker_kind/reason
// from the backend compute (Phase 1, task 1.2).
// =============================================================================
import { Component } from "@odoo/owl";
export class GateViz extends Component {
static template = "fusion_plating_shopfloor.GateViz";
static props = {
canStart: { type: Boolean, optional: false },
blockerKind: { type: String, optional: true },
blockerReason: { type: String, optional: true },
jumpTargetModel: { type: String, optional: true },
jumpTargetId: { type: Number, optional: true },
onJump: { type: Function, optional: true },
};
get iconClass() {
const map = {
predecessor: "fa-lock",
contract_review: "fa-file-text-o",
parts_not_received: "fa-truck",
racking_required: "fa-th-large",
manager_input: "fa-user-md",
};
return map[this.props.blockerKind] || "fa-pause-circle";
}
onJumpClick() {
if (this.props.onJump && this.props.jumpTargetModel && this.props.jumpTargetId) {
this.props.onJump({
model: this.props.jumpTargetModel,
id: this.props.jumpTargetId,
});
}
}
}
- Step 2: Create GateViz template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.GateViz">
<div class="o_fp_gate" t-if="!props.canStart">
<i t-att-class="'fa o_fp_gate_icon ' + iconClass"/>
<div class="o_fp_gate_body">
<div class="o_fp_gate_title">Can't start yet</div>
<div class="o_fp_gate_reason">
<t t-esc="props.blockerReason or 'Reason unknown — open the step in the back-office.'"/>
</div>
</div>
<button t-if="props.jumpTargetModel and props.jumpTargetId and props.onJump"
class="btn btn-sm btn-outline-warning o_fp_gate_jump"
t-on-click="onJumpClick">
Jump <i class="fa fa-arrow-right"/>
</button>
</div>
</t>
</templates>
- Step 3: Create GateViz SCSS
$o-webclient-color-scheme: bright !default;
$_gate-bg-hex: rgba(255, 159, 10, 0.10);
$_gate-border-hex: #ff9f0a;
$_gate-text-hex: #b06600;
@if $o-webclient-color-scheme == dark {
$_gate-text-hex: #ffb84d !global;
}
.o_fp_gate {
background: $_gate-bg-hex;
border-left: 3px solid $_gate-border-hex;
padding: 0.5rem 0.75rem;
border-radius: 0 6px 6px 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
.o_fp_gate_body { flex: 1; }
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; }
.o_fp_gate_jump { flex-shrink: 0; }
- Step 4: Register the 3 assets in manifest (both bundles)
Add the 3 lines after the WorkflowChip lines in BOTH web.assets_backend and web.assets_web_dark:
'fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss',
'fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml',
'fusion_plating_shopfloor/static/src/js/components/gate_viz.js',
- Step 5: Update and verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
- Step 6: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/gate_viz.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): GateViz shared OWL service"
Task 1.5 — SignaturePad shared OWL component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_pad.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py(add 3 lines × 2 bundles) -
Step 1: Create SignaturePad JS (Dialog component)
/** @odoo-module **/
// =============================================================================
// Fusion Plating — SignaturePad (shared OWL service)
// Modal canvas signature capture. Returns dataURI via onSubmit, caller commits.
// Mounted via the dialog service: `dialog.add(FpSignaturePad, { props })`.
// =============================================================================
import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class FpSignaturePad extends Component {
static template = "fusion_plating_shopfloor.SignaturePad";
static components = { Dialog };
static props = {
close: Function, // dialog service injects
title: { type: String, optional: true },
contextLabel: { type: String, optional: true },
onSubmit: { type: Function, optional: false }, // (dataUri) => void
onCancel: { type: Function, optional: true },
};
setup() {
this.canvasRef = useRef("canvas");
this.isDrawing = false;
this.lastPoint = null;
this.hasInk = false;
onMounted(() => {
const canvas = this.canvasRef.el;
// Match canvas pixel size to its CSS box so strokes don't stretch
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const ctx = canvas.getContext("2d");
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.strokeStyle = "#000";
canvas.addEventListener("pointerdown", this._onDown);
canvas.addEventListener("pointermove", this._onMove);
canvas.addEventListener("pointerup", this._onUp);
canvas.addEventListener("pointercancel", this._onUp);
canvas.addEventListener("pointerleave", this._onUp);
});
onWillUnmount(() => {
const canvas = this.canvasRef.el;
if (!canvas) return;
canvas.removeEventListener("pointerdown", this._onDown);
canvas.removeEventListener("pointermove", this._onMove);
canvas.removeEventListener("pointerup", this._onUp);
canvas.removeEventListener("pointercancel", this._onUp);
canvas.removeEventListener("pointerleave", this._onUp);
});
this._onDown = (ev) => {
this.isDrawing = true;
this.lastPoint = this._localPoint(ev);
};
this._onMove = (ev) => {
if (!this.isDrawing) return;
const p = this._localPoint(ev);
const ctx = this.canvasRef.el.getContext("2d");
ctx.beginPath();
ctx.moveTo(this.lastPoint.x, this.lastPoint.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
this.lastPoint = p;
this.hasInk = true;
};
this._onUp = () => {
this.isDrawing = false;
this.lastPoint = null;
};
}
_localPoint(ev) {
const r = this.canvasRef.el.getBoundingClientRect();
return { x: ev.clientX - r.left, y: ev.clientY - r.top };
}
onClear() {
const canvas = this.canvasRef.el;
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
this.hasInk = false;
}
onSubmit() {
if (!this.hasInk) return;
const dataUri = this.canvasRef.el.toDataURL("image/png");
this.props.onSubmit(dataUri);
this.props.close();
}
onCancel() {
if (this.props.onCancel) this.props.onCancel();
this.props.close();
}
}
- Step 2: Create SignaturePad template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.SignaturePad">
<Dialog title="props.title or 'Signature required'" size="'md'">
<div class="o_fp_sig_pad">
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
<t t-esc="props.contextLabel"/>
</div>
<canvas class="o_fp_sig_canvas" t-ref="canvas"/>
<div class="o_fp_sig_hint">Draw your signature above</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-secondary" t-on-click="onClear">Clear</button>
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
<button class="btn btn-primary" t-on-click="onSubmit">Sign & Finish</button>
</t>
</Dialog>
</t>
</templates>
- Step 3: Create SignaturePad SCSS
$o-webclient-color-scheme: bright !default;
$_sig-canvas-bg-hex: #ffffff;
$_sig-canvas-border-hex: #d8dadd;
@if $o-webclient-color-scheme == dark {
$_sig-canvas-bg-hex: #f5f5f5 !global; // signatures stay light for legibility
$_sig-canvas-border-hex: #5a5a5e !global;
}
.o_fp_sig_pad {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.o_fp_sig_ctx {
font-size: 0.85rem;
color: var(--text-secondary, #666);
}
.o_fp_sig_canvas {
width: 100%;
height: 200px;
background: $_sig-canvas-bg-hex;
border: 2px solid $_sig-canvas-border-hex;
border-radius: 6px;
cursor: crosshair;
touch-action: none;
}
.o_fp_sig_hint {
font-size: 0.75rem;
color: var(--text-secondary, #999);
text-align: center;
}
- Step 4: Register the 3 assets in manifest (both bundles)
Same pattern — append after GateViz lines in both web.assets_backend and web.assets_web_dark:
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
- Step 5: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_pad.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): SignaturePad shared OWL service"
Task 1.6 — HoldComposer shared OWL component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/hold_composer.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Create HoldComposer JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — HoldComposer (shared OWL service)
// Modal form to create a quality.hold with reason + qty + photo + notes.
// Calls /fp/workspace/hold (Phase 1 backend). Caller passes onCreated(hold).
// =============================================================================
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
const HOLD_REASONS = [
{ value: "dimensional", label: "Dimensional" },
{ value: "thickness", label: "Thickness fail" },
{ value: "plating_defect", label: "Plating defect" },
{ value: "contamination", label: "Contamination" },
{ value: "wrong_part", label: "Wrong part" },
{ value: "other", label: "Other" },
];
export class FpHoldComposer extends Component {
static template = "fusion_plating_shopfloor.HoldComposer";
static components = { Dialog };
static props = {
close: Function,
jobId: { type: Number, optional: false },
stepId: { type: Number, optional: true },
defaultQty: { type: Number, optional: true },
partRef: { type: String, optional: true },
onCreated: { type: Function, optional: true },
};
setup() {
this.notification = useService("notification");
this.reasons = HOLD_REASONS;
this.state = useState({
reason: "dimensional",
qty: this.props.defaultQty || 1,
description: "",
photoDataUri: null,
photoFilename: "",
markForScrap: false,
submitting: false,
});
}
onPhotoChange(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
// Strip "data:...;base64," prefix — backend expects raw base64
const dataUri = e.target.result;
const base64 = dataUri.split(",", 2)[1] || "";
this.state.photoDataUri = base64;
this.state.photoFilename = file.name;
};
reader.readAsDataURL(file);
}
async onSubmit() {
if (this.state.qty < 1) {
this.notification.add("Qty on hold must be at least 1", { type: "warning" });
return;
}
this.state.submitting = true;
try {
const res = await rpc("/fp/workspace/hold", {
job_id: this.props.jobId,
step_id: this.props.stepId || null,
part_ref: this.props.partRef || "",
reason: this.state.reason,
qty_on_hold: this.state.qty,
description: this.state.description || "",
mark_for_scrap: this.state.markForScrap,
photo_data: this.state.photoDataUri,
photo_filename: this.state.photoFilename,
});
if (res && res.ok) {
this.notification.add(`Hold ${res.hold_name} created.`, { type: "success" });
if (this.props.onCreated) this.props.onCreated(res);
this.props.close();
} else {
this.notification.add(res.error || "Hold creation failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
} finally {
this.state.submitting = false;
}
}
}
- Step 2: Create HoldComposer template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.HoldComposer">
<Dialog title="'Place hold'" size="'md'">
<div class="o_fp_hc">
<div class="o_fp_hc_row">
<label>Reason</label>
<select class="form-select" t-model="state.reason">
<t t-foreach="reasons" t-as="r" t-key="r.value">
<option t-att-value="r.value"><t t-esc="r.label"/></option>
</t>
</select>
</div>
<div class="o_fp_hc_row">
<label>Qty on hold</label>
<input type="number" min="1" class="form-control" t-model.number="state.qty"/>
</div>
<div class="o_fp_hc_row">
<label>Description</label>
<textarea class="form-control" rows="3" t-model="state.description"
placeholder="What happened?"/>
</div>
<div class="o_fp_hc_row">
<label>Photo (optional)</label>
<input type="file" accept="image/*" capture="environment"
class="form-control" t-on-change="onPhotoChange"/>
<small t-if="state.photoFilename" class="text-success">
<i class="fa fa-check"/> <t t-esc="state.photoFilename"/>
</small>
</div>
<div class="o_fp_hc_row">
<label class="form-check-label">
<input type="checkbox" class="form-check-input me-1"
t-model="state.markForScrap"/>
Mark for scrap
</label>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="() => this.props.close()">Cancel</button>
<button class="btn btn-warning" t-on-click="onSubmit"
t-att-disabled="state.submitting">
<i class="fa fa-exclamation-triangle me-1"/> Create Hold
</button>
</t>
</Dialog>
</t>
</templates>
- Step 3: Create HoldComposer SCSS
.o_fp_hc {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.o_fp_hc_row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.o_fp_hc_row label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #666);
}
- Step 4: Register assets, update, commit
Add 3 lines after SignaturePad in both bundles:
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/hold_composer.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): HoldComposer shared OWL service"
Task 1.7 — KanbanCard shared OWL component
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/components/kanban_card.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Create KanbanCard JS
/** @odoo-module **/
// =============================================================================
// Fusion Plating — KanbanCard (shared OWL service)
// Standard WO/step card used on Landing kanban + Manager Plant Board/Funnel.
// Embeds WorkflowChip + GateViz badge.
// =============================================================================
import { Component } from "@odoo/owl";
import { WorkflowChip } from "./workflow_chip";
export class FpKanbanCard extends Component {
static template = "fusion_plating_shopfloor.KanbanCard";
static components = { WorkflowChip };
static props = {
data: { type: Object, optional: false },
// {job_id, display_wo_name, customer, part, qty, qty_done, qty_scrapped,
// date_deadline, workflow_state, blocker_kind, blocker_reason,
// urgency_band, priority, current_step_id, work_center}
density: { type: String, optional: true }, // 'compact' | 'normal'
showWorkflowChip: { type: Boolean, optional: true },
showWorkcenter: { type: Boolean, optional: true },
showAssignedTo: { type: Boolean, optional: true },
onTap: { type: Function, optional: true },
};
get isCompact() {
return this.props.density === "compact";
}
get progressPct() {
const d = this.props.data;
if (!d.qty || d.qty <= 0) return 0;
return Math.min(100, Math.round((d.qty_done || 0) * 100 / d.qty));
}
get priorityDot() {
const p = this.props.data.priority;
if (p === "rush") return "danger";
if (p === "high") return "warning";
return "muted";
}
onClick() {
if (this.props.onTap) this.props.onTap(this.props.data);
}
}
- Step 2: Create KanbanCard template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.KanbanCard">
<div t-att-class="'o_fp_kcard ' + (isCompact ? 'o_fp_kcard_compact' : '')"
t-on-click="onClick">
<div class="o_fp_kcard_h1">
<span class="o_fp_kcard_wo"><t t-esc="props.data.display_wo_name"/></span>
<span t-att-class="'o_fp_kcard_pri o_fp_kcard_pri_' + priorityDot"/>
</div>
<div class="o_fp_kcard_h2">
<span t-esc="props.data.customer or ''"/>
<t t-if="props.data.part"> · <t t-esc="props.data.part"/></t>
</div>
<div class="o_fp_kcard_qty" t-if="props.data.qty">
<span><t t-esc="props.data.qty_done or 0"/> / <t t-esc="props.data.qty"/> done</span>
<span t-if="props.data.date_deadline" class="o_fp_kcard_due">
Due <t t-esc="props.data.date_deadline"/>
</span>
</div>
<div class="o_fp_kcard_bar" t-if="props.data.qty">
<div t-att-style="'width: ' + progressPct + '%'"/>
</div>
<div class="o_fp_kcard_chips">
<WorkflowChip t-if="props.showWorkflowChip and props.data.workflow_state"
state="props.data.workflow_state"/>
<span t-if="props.data.blocker_kind and props.data.blocker_kind !== 'none'"
class="o_fp_kcard_blocked"
t-att-title="props.data.blocker_reason">
<i class="fa fa-lock"/> Blocked
</span>
<span t-if="props.showWorkcenter and props.data.work_center"
class="o_fp_kcard_wc">
@ <t t-esc="props.data.work_center"/>
</span>
</div>
</div>
</t>
</templates>
- Step 3: Create KanbanCard SCSS
$o-webclient-color-scheme: bright !default;
$_kc-bg-hex: #ffffff;
$_kc-border-hex: #d8dadd;
$_kc-hover-hex: #f5f5f7;
@if $o-webclient-color-scheme == dark {
$_kc-bg-hex: #22262d !global;
$_kc-border-hex: #424245 !global;
$_kc-hover-hex: #2d3138 !global;
}
.o_fp_kcard {
background: $_kc-bg-hex;
border: 1px solid $_kc-border-hex;
border-radius: 6px;
padding: 0.55rem 0.7rem;
cursor: pointer;
transition: background 0.1s ease;
&:hover { background: $_kc-hover-hex; }
}
.o_fp_kcard_compact { padding: 0.4rem 0.55rem; }
.o_fp_kcard_h1 { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 0.85rem; }
.o_fp_kcard_h2 { color: var(--text-secondary, #666); font-size: 0.75rem; margin-top: 0.15rem; }
.o_fp_kcard_qty { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--text-secondary, #777); margin-top: 0.3rem; }
.o_fp_kcard_due { color: var(--text-secondary, #999); }
.o_fp_kcard_bar { height: 4px; background: rgba(0,0,0,0.08); border-radius: 2px; overflow: hidden; margin-top: 0.3rem;
> div { height: 100%; background: #34c759; }
}
.o_fp_kcard_chips { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.o_fp_kcard_pri { width: 8px; height: 8px; border-radius: 50%; }
.o_fp_kcard_pri_danger { background: #ff3b30; }
.o_fp_kcard_pri_warning { background: #ff9f0a; }
.o_fp_kcard_pri_muted { background: transparent; }
.o_fp_kcard_blocked { background: rgba(255,159,10,0.15); color: #b06600; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
.o_fp_kcard_wc { color: var(--text-secondary, #999); font-size: 0.7rem; }
@if $o-webclient-color-scheme == dark {
.o_fp_kcard_blocked { color: #ffb84d; }
}
- Step 4: Register assets, update, commit
Add 3 lines after HoldComposer:
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/kanban_card.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): KanbanCard shared OWL service"
Task 1.8 — workspace_controller.py: /fp/workspace/load endpoint
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/__init__.py -
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py -
Step 1: Write the failing test
Create fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceLoad(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'WS Cust'})
self.product = self.env['product.product'].create({'name': 'WS Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/WS001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5,
})
def test_load_returns_full_payload(self):
res = self.url_open(
'/fp/workspace/load',
data='{"jsonrpc":"2.0","params":{"job_id":%d}}' % self.job.id,
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertTrue(res['ok'])
self.assertEqual(res['job']['display_wo_name'], 'WO # WS001')
self.assertEqual(res['job']['id'], self.job.id)
self.assertIn('steps', res)
self.assertIn('workflow_states', res)
self.assertIn('chatter', res)
def test_load_bad_job_id_returns_error(self):
res = self.url_open(
'/fp/workspace/load',
data='{"jsonrpc":"2.0","params":{"job_id":999999}}',
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertFalse(res['ok'])
self.assertIn('not found', res['error'].lower())
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
Expected: FAIL — endpoint does not exist (404).
- Step 3: Create the controller
Create fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py:
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSON-RPC endpoints for the Job Workspace client action.
Surfaces a single fp.job + step list + workflow milestones + side-panel
data (spec PDF, attachments, chatter) + action endpoints (hold, sign-off,
milestone advance).
"""
import base64
import logging
from odoo import http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpWorkspaceController(http.Controller):
# ----------------------------------------------------------------------
# Load
# ----------------------------------------------------------------------
@http.route('/fp/workspace/load', type='jsonrpc', auth='user')
def load(self, job_id):
env = request.env
job = env['fp.job'].browse(int(job_id))
if not job.exists():
_logger.warning("workspace/load: job %s not found", job_id)
return {'ok': False, 'error': f'Job {job_id} not found'}
# ---- Workflow milestones ----------------------------------------
all_states = env['fp.job.workflow.state'].search([], order='sequence, id')
current = job.workflow_state_id
passed_ids = set()
for ws in all_states:
passed_ids.add(ws.id)
if ws.id == current.id:
break
workflow_states = [{
'id': ws.id,
'name': ws.name,
'color': ws.color or 'grey',
'sequence': ws.sequence or 0,
'passed': ws.id in passed_ids,
'is_current': ws.id == current.id,
} for ws in all_states]
# ---- Steps ------------------------------------------------------
steps = []
for step in job.step_ids.sorted('sequence'):
override = job.override_ids.filtered(lambda o: o.node_id.id == step.recipe_node_id.id)
steps.append({
'id': step.id,
'sequence': step.sequence,
'sequence_display': (step.sequence or 0) // 10,
'name': step.name or '',
'kind': step.kind or 'other',
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
'state': step.state,
'assigned_user_id': step.assigned_user_id.id or False,
'assigned_user_name': step.assigned_user_id.name or '',
'work_centre_name': step.work_centre_id.name or '',
'duration_actual': step.duration_actual or 0,
'duration_expected': step.duration_expected or 0,
'date_started_iso': fp_format(
env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
) if step.date_started else '',
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'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)),
'can_start': step.state in ('ready', 'paused') and step.blocker_kind == 'none',
'blocker_kind': step.blocker_kind,
'blocker_reason': step.blocker_reason or '',
'blocker_jump_target_model': step.blocker_jump_target_model or '',
'blocker_jump_target_id': step.blocker_jump_target_id or 0,
'override_excluded': bool(override and not override.included),
'quick_look_prompt_count': len(getattr(step, 'quick_look_prompt_ids', [])),
})
# ---- Spec + attachments + chatter -------------------------------
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else False
attachments = env['ir.attachment'].search([
('res_model', '=', 'fp.job'),
('res_id', '=', job.id),
], limit=20)
chatter = job.message_ids.filtered(
lambda m: m.message_type in ('comment', 'notification')
).sorted('date', reverse=True)[:10]
# ---- Required cert state ----------------------------------------
required_certs = {
'needs': list(job._resolve_required_cert_types()) if hasattr(job, '_resolve_required_cert_types') else [],
'has_draft': bool(getattr(job, '_fp_has_draft_required_certs', lambda: False)()),
}
# ---- Active step (the one in_progress) --------------------------
active = job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
return {
'ok': True,
'job': {
'id': job.id,
'name': job.name,
'display_wo_name': job.display_wo_name,
'partner_name': job.partner_id.name or '',
'product_name': job.product_id.display_name or '',
'part_number': job.part_catalog_id.part_number if job.part_catalog_id else '',
'qty': int(job.qty or 0),
'qty_done': int(job.qty_done or 0),
'qty_scrapped': int(job.qty_scrapped or 0),
'date_deadline': fp_format(env, job.date_deadline, fmt='%Y-%m-%d') if job.date_deadline else '',
'state': job.state,
'workflow_state': {
'id': current.id, 'name': current.name, 'color': current.color or 'grey',
} if current else None,
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
'quality_hold_count': job.quality_hold_count or 0,
'priority': job.priority or 'normal',
},
'workflow_states': workflow_states,
'steps': steps,
'active_step_id': active.id if active else False,
'spec': {
'id': spec.id, 'name': spec.name,
} if spec else None,
'attachments': [
{'id': a.id, 'name': a.name, 'mimetype': a.mimetype or '', 'url': f'/web/content/{a.id}'}
for a in attachments
],
'chatter': [
{
'id': m.id,
'author': m.author_id.name or 'System',
'body': m.body or '',
'date': fp_format(env, m.date, fmt='%Y-%m-%d %H:%M') if m.date else '',
}
for m in chatter
],
'required_certs': required_certs,
}
- Step 4: Register the controller
In fusion_plating/fusion_plating_shopfloor/controllers/__init__.py, add:
from . import workspace_controller
- Step 5: Verify test passes
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
Expected: 2 tests pass.
- Step 6: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
fusion_plating/fusion_plating_shopfloor/controllers/__init__.py \
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/load endpoint + tests"
Task 1.9 — workspace_controller.py: /fp/workspace/hold endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py(rename totest_workspace_actions.pyfor clarity OR add to a new file) -
Step 1: Write the failing test
Create fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py:
# -*- coding: utf-8 -*-
import json
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceHold(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'Hold Cust'})
self.product = self.env['product.product'].create({'name': 'Hold Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/H001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 10,
})
def test_hold_creates_quality_hold(self):
payload = {
"jsonrpc": "2.0",
"params": {
"job_id": self.job.id,
"reason": "dimensional",
"qty_on_hold": 3,
"description": "Bracket bent on de-rack",
"part_ref": "Bracket Rev A",
},
}
res = self.url_open(
'/fp/workspace/hold',
data=json.dumps(payload),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertTrue(res['ok'])
hold = self.env['fusion.plating.quality.hold'].browse(res['hold_id'])
self.assertEqual(hold.qty_on_hold, 3)
self.assertEqual(hold.hold_reason, 'dimensional')
def test_hold_with_photo_creates_attachment(self):
import base64
png_1px = base64.b64encode(
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01'
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
).decode()
payload = {
"jsonrpc": "2.0",
"params": {
"job_id": self.job.id,
"reason": "thickness",
"qty_on_hold": 1,
"photo_data": png_1px,
"photo_filename": "evidence.png",
},
}
res = self.url_open(
'/fp/workspace/hold',
data=json.dumps(payload),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertTrue(res['ok'])
attachments = self.env['ir.attachment'].search([
('res_model', '=', 'fusion.plating.quality.hold'),
('res_id', '=', res['hold_id']),
])
self.assertGreaterEqual(len(attachments), 1)
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
Expected: FAIL — endpoint missing.
- Step 3: Add the endpoint to
workspace_controller.py
Append to FpWorkspaceController class:
# ----------------------------------------------------------------------
# Hold create (with photo attachment)
# ----------------------------------------------------------------------
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
part_ref='', step_id=None, mark_for_scrap=False,
photo_data=None, photo_filename=None):
env = request.env
job = env['fp.job'].browse(int(job_id))
if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'}
try:
hold_vals = {
'part_ref': part_ref or '',
'qty_on_hold': int(qty_on_hold),
'qty_original': int(job.qty or 0),
'hold_reason': reason or 'other',
'description': description or '',
'mark_for_scrap': bool(mark_for_scrap),
}
if 'x_fc_job_id' in env['fusion.plating.quality.hold']._fields:
hold_vals['x_fc_job_id'] = job.id
if step_id and 'x_fc_step_id' in env['fusion.plating.quality.hold']._fields:
hold_vals['x_fc_step_id'] = int(step_id)
hold = env['fusion.plating.quality.hold'].create(hold_vals)
# Attach photo if provided (base64 string from the camera input)
attachment_id = False
if photo_data:
try:
att = env['ir.attachment'].create({
'name': photo_filename or f'hold_{hold.id}.png',
'datas': photo_data,
'res_model': 'fusion.plating.quality.hold',
'res_id': hold.id,
'mimetype': 'image/png',
})
attachment_id = att.id
except Exception:
_logger.exception("Hold photo attach failed for hold %s", hold.id)
# Don't fail the whole hold — log and continue without photo
_logger.info(
"Hold %s created on job %s by uid %s, reason %s, qty %s",
hold.name, job.name, env.uid, reason, qty_on_hold,
)
return {
'ok': True,
'hold_id': hold.id,
'hold_name': hold.name,
'state': hold.state,
'attachment_id': attachment_id,
}
except Exception as exc:
_logger.exception("workspace/hold failed")
return {'ok': False, 'error': str(exc)}
- Step 4: Verify test passes
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
Expected: 4 tests pass total (2 load + 2 hold).
- Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/hold endpoint (with photo)"
Task 1.10 — workspace_controller.py: /fp/workspace/sign_off endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py -
Step 1: Add test to
test_workspace_actions.py
Append to that file:
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceSignOff(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/S001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
self.step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'ENP Plate',
'sequence': 50,
'state': 'in_progress',
})
def test_sign_off_rejects_empty_signature(self):
import json
res = self.url_open(
'/fp/workspace/sign_off',
data=json.dumps({"jsonrpc":"2.0","params":{
"step_id": self.step.id,
"signature_data_uri": "",
}}),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertFalse(res['ok'])
self.assertIn('signature', res['error'].lower())
def test_sign_off_finishes_step(self):
import json
sig = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
res = self.url_open(
'/fp/workspace/sign_off',
data=json.dumps({"jsonrpc":"2.0","params":{
"step_id": self.step.id,
"signature_data_uri": sig,
}}),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertTrue(res['ok'])
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
Expected: FAIL — endpoint missing.
- Step 3: Add the endpoint
Append to FpWorkspaceController:
# ----------------------------------------------------------------------
# Sign off + finish step (atomic)
# ----------------------------------------------------------------------
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri):
env = request.env
sig = (signature_data_uri or '').strip()
if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
return {'ok': False, 'error': 'A signature is required to finish this step.'}
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'}
# Attach signature as an ir.attachment on the step
# Strip "data:...;base64," prefix if present
if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1]
try:
env['ir.attachment'].create({
'name': f'signature_{step.id}.png',
'datas': sig,
'res_model': 'fp.job.step',
'res_id': step.id,
'mimetype': 'image/png',
})
except Exception:
_logger.exception("Sign-off attachment failed for step %s", step.id)
return {'ok': False, 'error': 'Failed to save signature'}
# Finish the step
try:
step.button_finish()
except Exception as exc:
_logger.exception("Sign-off step finish failed")
return {'ok': False, 'error': str(exc)}
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
return {
'ok': True,
'step_id': step.id,
'state': step.state,
}
- Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/sign_off endpoint"
Task 1.11 — workspace_controller.py: /fp/workspace/advance_milestone endpoint
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py -
Step 1: Add test
Append to test_workspace_actions.py:
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceAdvanceMilestone(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'M Cust'})
self.product = self.env['product.product'].create({'name': 'M Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/M001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_advance_no_action_returns_error(self):
import json
res = self.url_open(
'/fp/workspace/advance_milestone',
data=json.dumps({"jsonrpc":"2.0","params":{"job_id": self.job.id}}),
headers={'Content-Type': 'application/json'},
).json()['result']
# Job has no steps → no next_milestone_action → reject
self.assertFalse(res['ok'])
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
Expected: FAIL.
- Step 3: Add the endpoint
Append to FpWorkspaceController:
# ----------------------------------------------------------------------
# Milestone advance (mark_done / issue_certs / schedule_delivery / mark_shipped)
# ----------------------------------------------------------------------
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
def advance_milestone(self, job_id):
env = request.env
job = env['fp.job'].browse(int(job_id))
if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'}
if not job.next_milestone_action:
return {
'ok': False,
'error': 'No milestone advance available — finish all steps first.',
}
try:
job.action_advance_next_milestone()
except Exception as exc:
_logger.exception("workspace/advance_milestone failed")
return {'ok': False, 'error': str(exc)}
_logger.info(
"Job %s milestone advanced (action=%s) by uid %s",
job.name, job.next_milestone_action or '(now next)', env.uid,
)
# Re-read after the action so the response reflects the new state
job.invalidate_recordset(['workflow_state_id', 'next_milestone_action', 'next_milestone_label'])
return {
'ok': True,
'workflow_state': {
'id': job.workflow_state_id.id,
'name': job.workflow_state_id.name,
'color': job.workflow_state_id.color or 'grey',
} if job.workflow_state_id else None,
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
}
- Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/advance_milestone endpoint"
Task 1.12 — JobWorkspace OWL component: scaffold + header + workflow bar
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml -
Create:
fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py(register action + assets) -
Step 1: Create JobWorkspace JS scaffold
Create fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js:
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Job Workspace (full-screen WO surface)
// Client action: fp_job_workspace
// Opens from: kanban tap (Landing), smart button (fp.job form),
// QR scan (FP-JOB / FP-STEP), manager dashboard card tap.
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.dialog = useService("dialog");
this.state = useState({
data: null,
loading: false,
jobId: null,
focusStepId: null,
});
onMounted(async () => {
this.state.jobId = (this.props.action && this.props.action.params && this.props.action.params.job_id) || null;
this.state.focusStepId = (this.props.action && this.props.action.params && this.props.action.params.focus_step_id) || null;
await this.refresh();
this._refreshInterval = setInterval(() => this.refresh(), 15000);
});
onWillUnmount(() => {
if (this._refreshInterval) clearInterval(this._refreshInterval);
});
}
async refresh() {
if (!this.state.jobId) return;
try {
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
if (res && res.ok) {
this.state.data = res;
} else {
this.notification.add(res.error || "Failed to load workspace", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
}
}
onBack() {
this.action.doAction({ type: "ir.actions.act_window_close" });
}
onAdvanceMilestone() {
const advance = async () => {
try {
const res = await rpc("/fp/workspace/advance_milestone", { job_id: this.state.jobId });
if (res && res.ok) {
this.notification.add("Milestone advanced.", { type: "success" });
await this.refresh();
} else {
this.notification.add(res.error || "Advance failed", { type: "warning" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
};
advance();
}
// (Step actions, hold composer, sign-off — added in Task 1.13/1.14)
}
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
- Step 2: Create JobWorkspace template XML (header + workflow bar only — step list comes in Task 1.13)
Create fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml:
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.JobWorkspace">
<div class="o_fp_ws">
<!-- Loading state -->
<div t-if="!state.data" class="o_fp_ws_loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div>Loading Job Workspace…</div>
</div>
<t t-if="state.data">
<!-- ===== Sticky header ===== -->
<header class="o_fp_ws_head">
<div class="o_fp_ws_head_l">
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
<i class="fa fa-arrow-left"/> Back
</button>
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
<span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
<t t-if="state.data.job.part_number">
<span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_part"><t t-esc="state.data.job.part_number"/></span>
</t>
</div>
<div class="o_fp_ws_head_r">
<span class="o_fp_ws_pill">
<t t-esc="state.data.job.qty_done"/> / <t t-esc="state.data.job.qty"/> done
<t t-if="state.data.job.qty_scrapped">· <t t-esc="state.data.job.qty_scrapped"/> scrap</t>
</span>
<span class="o_fp_ws_pill" t-if="state.data.job.date_deadline">
Due <t t-esc="state.data.job.date_deadline"/>
</span>
<WorkflowChip t-if="state.data.job.workflow_state"
state="state.data.job.workflow_state"/>
<span t-att-class="'o_fp_ws_pill ' + (state.data.job.quality_hold_count ? 'o_fp_ws_holds_red' : 'o_fp_ws_holds_ok')">
<t t-esc="state.data.job.quality_hold_count"/> holds
</span>
</div>
</header>
<!-- ===== Sticky workflow bar ===== -->
<div class="o_fp_ws_bar">
<div class="o_fp_ws_bar_line">
<t t-foreach="state.data.workflow_states" t-as="ws" t-key="ws.id">
<div t-att-class="'o_fp_ws_dot_wrap' + (ws.is_current ? ' current' : (ws.passed ? ' done' : ''))">
<span class="o_fp_ws_bar_dot"/>
<span class="o_fp_ws_bar_label" t-esc="ws.name"/>
</div>
<span t-if="!ws_last" t-att-class="'o_fp_ws_link' + (ws.passed ? ' done' : '')"/>
</t>
</div>
<button t-if="state.data.job.next_milestone_action"
class="btn btn-primary o_fp_ws_next"
t-on-click="onAdvanceMilestone">
Next: <t t-esc="state.data.job.next_milestone_label"/> <i class="fa fa-arrow-right"/>
</button>
</div>
<!-- Step list + side panel placeholder — implemented in Task 1.13 -->
<div class="o_fp_ws_main">
<div class="o_fp_ws_steps">
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
<i class="fa fa-exclamation-circle"/>
<div>Recipe not generated for this WO.</div>
</div>
<!-- TODO Task 1.13: render step rows -->
</div>
<div class="o_fp_ws_side">
<!-- TODO Task 1.15: side panel -->
</div>
</div>
<!-- Action rail placeholder — implemented in Task 1.14 -->
<footer class="o_fp_ws_rail">
<!-- TODO Task 1.14: action buttons -->
</footer>
</t>
</div>
</t>
</templates>
- Step 3: Create JobWorkspace SCSS (scaffold)
Create fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss:
$o-webclient-color-scheme: bright !default;
$_ws-page-hex: #f3f4f6;
$_ws-card-hex: #ffffff;
$_ws-border-hex: #d8dadd;
$_ws-text-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_ws-page-hex: #1a1d21 !global;
$_ws-card-hex: #22262d !global;
$_ws-border-hex: #424245 !global;
$_ws-text-hex: #f5f5f7 !global;
}
.o_fp_ws {
display: flex;
flex-direction: column;
height: 100%;
background: $_ws-page-hex;
color: $_ws-text-hex;
overflow: hidden;
}
.o_fp_ws_loading {
margin: auto;
text-align: center;
color: var(--text-secondary, #666);
> div { margin-top: 0.6rem; }
}
.o_fp_ws_head {
background: $_ws-card-hex;
border-bottom: 1px solid $_ws-border-hex;
padding: 0.6rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.o_fp_ws_head_l { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.o_fp_ws_head_r { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.o_fp_ws_back { padding: 0.25rem 0.5rem; }
.o_fp_ws_wo { font-weight: 700; font-size: 1.1rem; }
.o_fp_ws_dot { color: var(--text-secondary, #999); }
.o_fp_ws_cust { color: var(--text-secondary, #555); }
.o_fp_ws_part { color: var(--text-secondary, #555); }
.o_fp_ws_pill {
background: $_ws-page-hex;
border: 1px solid $_ws-border-hex;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.78rem;
color: var(--text-secondary, #555);
}
.o_fp_ws_holds_ok { background: rgba(52,199,89,0.12); color: #1d6e2f; border-color: rgba(52,199,89,0.3); }
.o_fp_ws_holds_red { background: rgba(255,59,48,0.12); color: #b00018; border-color: rgba(255,59,48,0.3); }
.o_fp_ws_bar {
background: $_ws-page-hex;
border-bottom: 1px solid $_ws-border-hex;
padding: 0.55rem 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.o_fp_ws_bar_line { flex: 1; display: flex; align-items: center; gap: 0.3rem; }
.o_fp_ws_dot_wrap {
display: flex; flex-direction: column; align-items: center; min-width: 60px;
.o_fp_ws_bar_dot { width: 14px; height: 14px; border-radius: 50%; background: $_ws-border-hex; border: 2px solid $_ws-border-hex; }
.o_fp_ws_bar_label { font-size: 0.65rem; color: var(--text-secondary, #888); margin-top: 0.2rem; text-align: center; }
&.done .o_fp_ws_bar_dot { background: #34c759; border-color: #34c759; }
&.current .o_fp_ws_bar_dot { background: #0071e3; border-color: #0071e3; box-shadow: 0 0 0 3px rgba(0,113,227,0.25); }
&.current .o_fp_ws_bar_label { color: #0071e3; font-weight: 600; }
}
.o_fp_ws_link {
flex: 0 0 8px; height: 2px; background: $_ws-border-hex; margin-top: -16px;
&.done { background: #34c759; }
}
.o_fp_ws_next { white-space: nowrap; }
.o_fp_ws_main {
flex: 1;
display: grid;
grid-template-columns: 1.7fr 1fr;
overflow: hidden;
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.o_fp_ws_steps { padding: 0.7rem 1rem; overflow-y: auto; border-right: 1px solid $_ws-border-hex; }
.o_fp_ws_side { padding: 0.7rem 1rem; overflow-y: auto; background: $_ws-page-hex; }
.o_fp_ws_empty {
text-align: center; padding: 2rem 1rem; color: var(--text-secondary, #999);
> div { margin-top: 0.5rem; }
}
.o_fp_ws_rail {
background: $_ws-card-hex;
border-top: 1px solid $_ws-border-hex;
padding: 0.55rem 1rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
- Step 4: Register the JobWorkspace assets in manifest (both bundles)
Append after KanbanCard lines in BOTH web.assets_backend and web.assets_web_dark:
# ---- Job Workspace (Phase 1) ----
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
- Step 5: Update + manually verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
Then open Odoo at http://localhost:8069, run the dev console:
odoo.__WOWL_DEBUG__.root.env.services.action.doAction({
type: 'ir.actions.client',
tag: 'fp_job_workspace',
params: { job_id: <pick any fp.job id> },
target: 'current',
});
Expected: workspace renders the header strip and workflow bar with the job's milestones; main + footer regions empty (placeholders for next tasks).
- Step 6: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace scaffold + header + workflow bar"
Task 1.13 — JobWorkspace: step list rendering with GateViz
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js(add step action methods) -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml(replace step list TODO) -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss(add step row styles) -
Step 1: Add step action methods to JobWorkspace JS
Inside FpJobWorkspace class (replace the comment // (Step actions, hold composer, sign-off — added in Task 1.13/1.14) with):
iconForStepState(state) {
const map = {
ready: "○", paused: "⏸", in_progress: "▶",
done: "✓", skipped: "✕", cancelled: "✕",
};
return map[state] || "○";
}
isStepActive(step) {
return step.state === "in_progress";
}
async onStartStep(stepId) {
try {
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
if (res && res.ok) {
this.notification.add("Step started.", { type: "success" });
await this.refresh();
} else {
this.notification.add(res.error || "Start failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
async onFinishStep(step) {
if (step.requires_signoff) {
this.dialog.add(FpSignaturePad, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => {
try {
const res = await rpc("/fp/workspace/sign_off", {
step_id: step.id,
signature_data_uri: dataUri,
});
if (res && res.ok) {
this.notification.add("Step signed off and finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add(res.error || "Sign-off failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
},
});
return;
}
// Plain finish
try {
const res = await rpc("/fp/shopfloor/stop_wo", { workorder_id: step.id, finish: true });
if (res && res.ok) {
this.notification.add("Step finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add(res.error || "Finish failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
onJumpToBlocker({ model, id }) {
// Predecessor: just scroll into view if same workspace; otherwise open
const step = this.state.data.steps.find((s) => s.id === id);
if (step) {
const el = document.querySelector(`[data-step-id="${id}"]`);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
this.action.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: id,
views: [[false, "form"]],
target: "current",
});
}
}
Also add FpSignaturePad to the static components list. Replace:
static components = { WorkflowChip, GateViz };
with:
static components = { WorkflowChip, GateViz, FpSignaturePad };
- Step 2: Replace the step list TODO in
job_workspace.xml
Replace the <!-- TODO Task 1.13: render step rows --> line with:
<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.override_excluded ? ' excluded' : '') +
(step.blocker_kind !== 'none' ? ' blocked' : '')"
t-att-data-step-id="step.id">
<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 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'">
<div class="o_fp_ws_step_detail">
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
<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>
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">
⏱ Dwell <t t-esc="step.dwell_time_minutes"/> min
</span>
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">
🔥 Bake <t t-esc="step.bake_setpoint_temp"/>°
</span>
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
✎ Sign-off required
</span>
</div>
<div t-if="step.instructions and isStepActive(step)" class="o_fp_ws_step_instr">
<t t-esc="step.instructions"/>
</div>
<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 class="o_fp_ws_step_actions" t-if="isStepActive(step) and step.blocker_kind === 'none'">
<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)">
<button class="btn btn-primary"
t-on-click="() => this.onStartStep(step.id)">
<i class="fa fa-play"/> Start
</button>
</div>
</div>
</t>
</div>
</t>
- Step 3: Add step row styles to
job_workspace.scss
Append to that file:
.o_fp_ws_step {
background: $_ws-card-hex;
border: 1px solid $_ws-border-hex;
border-radius: 6px;
padding: 0.5rem 0.7rem;
margin-bottom: 0.4rem;
&.done { opacity: 0.7; }
&.active { border: 2px solid #0071e3; padding: 0.6rem 0.75rem; box-shadow: 0 0 0 1px #0071e3; }
&.blocked { background: rgba(255,159,10,0.06); border-color: #ff9f0a; }
&.excluded { opacity: 0.5; }
}
.o_fp_ws_step_l1 {
display: flex; align-items: center; gap: 0.45rem;
}
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
.o_fp_ws_step_name { font-weight: 600; }
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
.o_fp_ws_step_badge { background: #0071e3; color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.04em; margin-left: 0.4rem; }
.o_fp_ws_step_detail { margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed $_ws-border-hex; display: flex; flex-direction: column; gap: 0.4rem; }
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
// Inline chip helpers
.o_fp_chip { padding: 0.15rem 0.45rem; border-radius: 999px; font-size: 0.72rem; }
.o_fp_chip_info { background: rgba(0,113,227,0.12); color: #0050a0; }
.o_fp_chip_warning { background: rgba(255,159,10,0.15); color: #b06600; }
@if $o-webclient-color-scheme == dark {
.o_fp_chip_info { color: #6cb6ff; }
.o_fp_chip_warning { color: #ffb84d; }
}
- Step 4: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
Hard-refresh the browser, re-invoke the client action with a job that has steps. Expected: all steps render; active step auto-expanded with chips + instructions; blocked steps show GateViz; Start/Finish buttons work.
- Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace step list + GateViz integration"
Task 1.14 — JobWorkspace: Action rail (Hold + Note + Photo + Milestone)
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js(add hold + note methods) -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml(replace action rail TODO) -
Step 1: Add Hold + Note + Photo methods to JobWorkspace JS
Inside FpJobWorkspace, before the closing } of the class:
onCreateHold() {
this.dialog.add(FpHoldComposer, {
jobId: this.state.jobId,
defaultQty: (this.state.data.job.qty - this.state.data.job.qty_done) || 1,
partRef: this.state.data.job.part_number || "",
onCreated: () => this.refresh(),
});
}
async onAddNote() {
const text = window.prompt("Add a note to this WO:");
if (!text) return;
try {
// Use Odoo's message_post via ORM call
await rpc("/web/dataset/call_kw", {
model: "fp.job",
method: "message_post",
args: [[this.state.jobId]],
kwargs: { body: text, message_type: "comment" },
});
this.notification.add("Note added.", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
Also add FpHoldComposer to the components list. Replace:
static components = { WorkflowChip, GateViz, FpSignaturePad };
with:
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
- Step 2: Replace the action rail TODO in
job_workspace.xml
Replace <!-- TODO Task 1.14: action buttons --> with:
<button class="btn btn-warning" t-on-click="onCreateHold">
<i class="fa fa-exclamation-triangle"/> Create Hold
</button>
<button class="btn btn-light" t-on-click="onAddNote">
<i class="fa fa-pencil"/> Note
</button>
<span style="flex: 1"/>
<button t-if="state.data.required_certs.has_draft"
class="btn btn-light"
t-on-click="onAdvanceMilestone">
<i class="fa fa-file-text"/> Issue Cert
</button>
<button t-if="state.data.job.next_milestone_action"
class="btn btn-primary"
t-on-click="onAdvanceMilestone">
<i class="fa fa-arrow-right"/> <t t-esc="state.data.job.next_milestone_label"/>
</button>
- Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
Open a workspace, hit Create Hold → composer opens → fill form → submit → hold appears in count badge after refresh.
- Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace action rail (Hold + Note + Milestone)"
Task 1.15 — JobWorkspace: side panel (spec + attachments + chatter)
Files:
-
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml(replace side panel TODO) -
Modify:
fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss(side panel styles) -
Step 1: Replace side panel TODO in
job_workspace.xml
Replace <!-- TODO Task 1.15: side panel --> with:
<div class="o_fp_ws_side_card" t-if="state.data.spec">
<h4>Customer spec</h4>
<div class="o_fp_ws_spec_link">
<i class="fa fa-file-pdf-o"/>
<a t-att-href="'/web/content/' + state.data.spec.id"
target="_blank"><t t-esc="state.data.spec.name"/></a>
</div>
</div>
<div class="o_fp_ws_side_card" t-if="state.data.attachments.length">
<h4>Drawings & attachments</h4>
<t t-foreach="state.data.attachments" t-as="att" t-key="att.id">
<div class="o_fp_ws_attach">
<span>📄 <t t-esc="att.name"/></span>
<a t-att-href="att.url" target="_blank">Open</a>
</div>
</t>
</div>
<div class="o_fp_ws_side_card">
<h4>Notes</h4>
<div t-if="!state.data.chatter.length" class="o_fp_ws_empty_small">
No notes yet.
</div>
<t t-foreach="state.data.chatter" t-as="msg" t-key="msg.id">
<div class="o_fp_ws_note">
<div class="o_fp_ws_note_h">
<span class="author"><t t-esc="msg.author"/></span>
<span class="time"><t t-esc="msg.date"/></span>
</div>
<div class="body" t-out="msg.body"/>
</div>
</t>
</div>
- Step 2: Add side panel styles to
job_workspace.scss
Append:
.o_fp_ws_side_card {
background: $_ws-card-hex;
border: 1px solid $_ws-border-hex;
border-radius: 6px;
padding: 0.55rem 0.7rem;
margin-bottom: 0.45rem;
h4 { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary, #777); margin-bottom: 0.35rem; }
}
.o_fp_ws_spec_link { display: flex; gap: 0.4rem; align-items: center; font-size: 0.82rem; }
.o_fp_ws_attach { display: flex; justify-content: space-between; padding: 0.2rem 0; font-size: 0.78rem; border-bottom: 1px dashed $_ws-border-hex;
&:last-child { border-bottom: none; }
}
.o_fp_ws_note { font-size: 0.78rem; padding: 0.3rem 0; }
.o_fp_ws_note_h { display: flex; gap: 0.4rem; font-size: 0.72rem; color: var(--text-secondary, #777); }
.o_fp_ws_note .author { font-weight: 600; }
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
.o_fp_ws_empty_small { color: var(--text-secondary, #999); font-size: 0.75rem; font-style: italic; }
- Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
Open a workspace; verify side panel shows spec link (if set), attachments (if any), and chatter.
- Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace side panel (spec + attachments + chatter)"
Task 1.16 — Add "Open Workspace" smart button on fp.job form
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/models/fp_job.py(action method) -
Modify:
fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml(button) -
Step 1: Add the Python action method
In fusion_plating/fusion_plating_jobs/models/fp_job.py, add this method to the FpJob class (after existing methods):
def action_open_workspace(self):
"""Open the JobWorkspace OWL client action focused on this job."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_job_workspace',
'name': self.display_wo_name or self.name,
'params': {'job_id': self.id},
'target': 'current',
}
- Step 2: Add the header button to the form view
In fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml, add inside the <xpath expr="//header" position="inside"> block (after the Process Tree button):
<button name="action_open_workspace" type="object"
string="Open Workspace"
class="btn-primary"
icon="fa-tablet"
invisible="state == 'draft'"/>
- Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --stop-after-init
Open any non-draft fp.job form → click "Open Workspace" → JobWorkspace opens.
- Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
git commit -m "feat(fusion_plating_jobs): Open Workspace smart button on fp.job form"
Task 1.17 — Phase 1 manifest + version finalization
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/__manifest__.py -
Step 1: Bump fusion_plating_jobs version
Change:
'version': '19.0.10.18.0',
to:
'version': '19.0.10.19.0',
- Step 2: Bump fusion_plating_shopfloor version (if not already done)
Verify 'version': '19.0.27.0.0', in fusion_plating_shopfloor/__manifest__.py.
- Step 3: Full Phase 1 integration smoke test
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fp_jobs,fp_shopfloor --stop-after-init
Expected: all Phase 1 tests pass.
Manual smoke: open Odoo, navigate to any active fp.job, click Open Workspace, exercise: tap step → see chips/instructions → Finish on a regular step → Finish & Sign Off on a sign-off step → Create Hold → Advance Milestone.
- Step 4: Commit + tag Phase 1 complete
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/__manifest__.py
git commit -m "chore(fusion_plating): bump versions for Phase 1 — Workspace foundation"
git tag phase1-workspace-foundation
Phase 2 — Auto-pause cron + ACL lift + supporting computes
Goal: Add the auto-pause cron (fixes 411-hour ghost timer), late_risk_ratio + active_step_id computes (used by Manager At-Risk + Workspace focus), long_running opt-out on recipe nodes, and lift ACLs so technicians don't hit permission walls during the daily flow.
Task 2.1 — Add long_running Boolean to fusion.plating.process.node
Files:
-
Modify:
fusion_plating/fusion_plating/models/fp_process_node.py -
Step 1: Add the field
In fp_process_node.py, on the FpProcessNode class, add:
long_running = fields.Boolean(
string='Long-running step',
default=False,
help='When True, steps generated from this recipe node are exempt '
'from the auto-pause cron (used for 24h bakes, multi-shift '
'long soaks, etc.).',
)
- Step 2: Surface in the process node form view
In whichever view defines the process node form, add the field somewhere appropriate (e.g. next to a "Skippable / Sequential" toggle group). If the file is fusion_plating/views/fp_process_node_views.xml, add inside the appropriate <group>:
<field name="long_running"/>
If you can't locate the form view definition file via grep -rn 'fp_process_node\|FpProcessNode' fusion_plating/views/, defer the view update (manager can still tick the flag via dev mode form). Document in commit message.
- Step 3: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating/models/fp_process_node.py \
fusion_plating/fusion_plating/views/fp_process_node_views.xml # if updated
git commit -m "feat(fusion_plating): long_running flag on process node (auto-pause opt-out)"
Task 2.2 — Add late_risk_ratio compute on fp.job
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/models/fp_job.py -
Create:
fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py -
Step 1: Write the failing test
# fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py
# -*- coding: utf-8 -*-
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestLateRiskRatio(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'LR'})
self.product = self.env['product.product'].create({'name': 'LR'})
def _make_job(self, deadline=None):
return self.env['fp.job'].create({
'name': 'WH/JOB/LR',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
'date_deadline': deadline,
})
def test_no_deadline_zero(self):
job = self._make_job(deadline=False)
self.assertEqual(job.late_risk_ratio, 0.0)
def test_no_open_steps_zero(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=8))
# No steps → nothing remaining → no risk
self.assertEqual(job.late_risk_ratio, 0.0)
def test_ratio_above_one_when_overrun(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=2))
# 1 step planned for 240 min, only 120 min left → ratio = 2.0
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Long',
'sequence': 10,
'state': 'ready',
'duration_expected': 240,
})
job.invalidate_recordset(['late_risk_ratio'])
self.assertGreaterEqual(job.late_risk_ratio, 1.5)
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
- Step 3: Implement the compute
In fp_job.py, add to the FpJob class:
late_risk_ratio = fields.Float(
compute='_compute_late_risk_ratio',
store=True,
string='Late-risk Ratio',
help='remaining_planned_minutes / minutes_to_deadline. '
'>1.0 means the job will be late if nothing changes. '
'Drives the At-Risk view on the manager dashboard.',
)
@api.depends('step_ids.state', 'step_ids.duration_expected', 'date_deadline')
def _compute_late_risk_ratio(self):
from datetime import datetime
for job in self:
if not job.date_deadline:
job.late_risk_ratio = 0.0
continue
open_steps = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
)
remaining_planned = sum(open_steps.mapped('duration_expected') or [0])
if remaining_planned <= 0:
job.late_risk_ratio = 0.0
continue
now = datetime.now()
# date_deadline is naive UTC; compare directly
minutes_to_deadline = max(
1.0,
(job.date_deadline - now).total_seconds() / 60.0,
)
job.late_risk_ratio = remaining_planned / minutes_to_deadline
- Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py
git commit -m "feat(fusion_plating_jobs): fp.job.late_risk_ratio compute"
Task 2.3 — Add active_step_id compute on fp.job
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/models/fp_job.py -
Create:
fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py -
Step 1: Write the failing test
# fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestActiveStepId(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'AS'})
self.product = self.env['product.product'].create({'name': 'AS'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/AS', 'partner_id': self.partner.id,
'product_id': self.product.id, 'qty': 1,
})
def test_no_active_step(self):
self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'S1', 'sequence': 10, 'state': 'ready',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertFalse(self.job.active_step_id.id)
def test_single_in_progress_step(self):
s = self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'S1', 'sequence': 10, 'state': 'in_progress',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertEqual(self.job.active_step_id.id, s.id)
def test_multiple_in_progress_picks_lowest_sequence(self):
s1 = self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'S1', 'sequence': 10, 'state': 'in_progress',
})
self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'S2', 'sequence': 20, 'state': 'in_progress',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertEqual(self.job.active_step_id.id, s1.id)
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
- Step 3: Implement
In fp_job.py, add:
active_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_active_step_id',
string='Active Step',
help='Currently in-progress step (lowest sequence if multiple — '
'shouldn\'t happen but defensive). Drives Workspace landing focus.',
)
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_active_step_id(self):
for job in self:
active = job.step_ids.filtered(lambda s: s.state == 'in_progress').sorted('sequence')
job.active_step_id = active[:1].id if active else False
- Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py
git commit -m "feat(fusion_plating_jobs): fp.job.active_step_id compute"
Task 2.4 — Auto-pause cron method _cron_autopause_stale_steps
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/models/fp_job_step.py -
Create:
fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py -
Step 1: Write the failing test
# fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py
# -*- coding: utf-8 -*-
from datetime import datetime, timedelta
from unittest.mock import patch
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestAutopauseCron(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'AP'})
self.product = self.env['product.product'].create({'name': 'AP'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/AP',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_stale_step_flips_to_paused(self):
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=10),
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'paused')
def test_fresh_step_unchanged(self):
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S2',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=2),
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'in_progress')
def test_long_running_step_exempt(self):
# Create a recipe node flagged long_running, link a step to it
node = self.env['fusion.plating.process.node'].create({
'name': 'Long bake',
'long_running': True,
})
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S3',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=20),
'recipe_node_id': node.id,
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'in_progress')
- Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
- Step 3: Implement the cron method
At the top of fp_job_step.py, ensure imports include:
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
import logging
_logger = logging.getLogger(__name__)
Then add to the class:
@api.model
def _cron_autopause_stale_steps(self):
"""Flip in_progress steps idle > threshold to paused, with chatter audit.
Recipes can opt out per node via recipe_node_id.long_running.
Threshold from ir.config_parameter `fp.shopfloor.autopause_threshold_hours`
(default 8). Skipped silently for nodes with long_running=True.
"""
threshold = float(
self.env['ir.config_parameter'].sudo()
.get_param('fp.shopfloor.autopause_threshold_hours', 8)
)
deadline = fields.Datetime.now() - timedelta(hours=threshold)
domain = [
('state', '=', 'in_progress'),
('date_started', '<', deadline),
'|',
('recipe_node_id', '=', False),
('recipe_node_id.long_running', '=', False),
]
stale = self.search(domain)
for step in stale:
try:
step.button_pause()
step.message_post(body=Markup(
"<b>Auto-paused</b> after %.1fh idle. "
"Resume from the tablet when work continues."
) % threshold)
_logger.info(
"Auto-paused step %s (%s) after %.1fh idle",
step.id, step.name, threshold,
)
except Exception:
_logger.exception(
"Auto-pause failed for step %s — skipping", step.id,
)
return len(stale)
If button_pause doesn't exist on the step model yet, add a minimal implementation in the same file:
def button_pause(self):
for step in self:
if step.state == 'in_progress':
step.state = 'paused'
return True
- Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job_step.py \
fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py
git commit -m "feat(fusion_plating_jobs): _cron_autopause_stale_steps method"
Task 2.5 — Register the auto-pause cron in data XML
Files:
-
Modify:
fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml -
Step 1: Add the cron record
Append (inside <odoo> … </odoo>):
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
<field name="name">FP Jobs: auto-pause stale in-progress steps</field>
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
<field name="state">code</field>
<field name="code">model._cron_autopause_stale_steps()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
- Step 2: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --stop-after-init
Verify in Odoo UI: Settings → Technical → Scheduled Actions → search "auto-pause" → see the new cron, next-call ~30 min out.
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
git commit -m "feat(fusion_plating_jobs): register auto-pause stale steps cron (30 min)"
Task 2.6 — ACL lift for operator group
Files:
-
Modify:
fusion_plating/fusion_plating_certificates/security/ir.model.access.csv -
Modify:
fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv -
Modify:
fusion_plating/fusion_plating_certificates/__manifest__.py(version bump) -
Step 1: Extend operator perms on certificate and thickness reading
In fusion_plating/fusion_plating_certificates/security/ir.model.access.csv, change the operator lines:
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
to:
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0
(Cert: add write. Thickness: add write + create.)
Bump fusion_plating_certificates version (whatever it is + .1).
- Step 2: Add operator read on fp.job.node.override
In fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv, append (the access csv lives in shopfloor since shopfloor is the consumer):
access_fp_job_node_override_operator,fp.job.node.override.operator,fusion_plating_jobs.model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
- Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_certificates,fusion_plating_shopfloor --stop-after-init
In Odoo, log in as an operator-only user (or impersonate). Verify they can:
-
Read a fp.certificate (already worked)
-
Edit a fp.certificate (NEW)
-
Read/Create a fp.thickness.reading (NEW)
-
Read a fp.job.node.override (NEW)
-
CANNOT write/unlink overrides (rejected)
-
Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_certificates/security/ir.model.access.csv \
fusion_plating/fusion_plating_certificates/__manifest__.py \
fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
git commit -m "feat(fusion_plating): lift operator ACL for cert write + thickness create + override read"
Task 2.7 — Phase 2 wrap-up — version bumps + smoke test
Files:
-
Modify:
fusion_plating/fusion_plating/__manifest__.py -
Modify:
fusion_plating/fusion_plating_jobs/__manifest__.py -
Modify:
fusion_plating/fusion_plating_shopfloor/__manifest__.py -
Step 1: Bump versions
-
fusion_plating: 19.0.20.6.2 → 19.0.20.7.0 -
fusion_plating_jobs: 19.0.10.19.0 → 19.0.10.20.0 -
fusion_plating_shopfloor: 19.0.27.0.0 → 19.0.27.1.0 -
Step 2: Smoke test
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating,fusion_plating_jobs,fusion_plating_certificates,fusion_plating_shopfloor --stop-after-init
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fp_jobs,fp_shopfloor --stop-after-init
All tests green.
Manually trigger the cron: in Odoo UI → Technical → Scheduled Actions → "FP Jobs: auto-pause stale in-progress steps" → Run Manually. Check log for Auto-paused step ... lines.
- Step 3: Commit + tag Phase 2 complete
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating/__manifest__.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(fusion_plating): bump versions for Phase 2 — cron + ACL lift"
git tag phase2-cron-and-acl
Phase 3 — Landing Refactor
Goal: Replace fp_shopfloor_tablet and fp_plant_overview with a unified fp_shopfloor_landing (station-scoped kanban + All Plant toggle). Tap card → JobWorkspace (already shipped in Phase 1).
Task 3.1 — landing_controller.py: /fp/landing/kanban endpoint
Files:
-
Create:
fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py -
Modify:
fusion_plating/fusion_plating_shopfloor/controllers/__init__.py -
Create:
fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py -
Step 1: Write the failing test
# fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py
# -*- coding: utf-8 -*-
import json
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestLandingKanban(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
def test_all_plant_mode_returns_columns(self):
res = self.url_open(
'/fp/landing/kanban',
data=json.dumps({"jsonrpc":"2.0","params":{"mode":"all_plant"}}),
headers={'Content-Type': 'application/json'},
).json()['result']
self.assertTrue(res['ok'])
self.assertIn('columns', res)
self.assertIn('kpis', res)
# KPIs expected: ready, running, bakes_due, holds
self.assertIn('ready', res['kpis'])
-
Step 2: Verify test fails
-
Step 3: Create
landing_controller.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""JSON-RPC endpoint for the Shop Floor Landing kanban.
Two modes: 'station' (paired-station scope + Unassigned + next 1-2 WCs in
recipe flow) and 'all_plant' (every active WC, recipe-flow order).
"""
import logging
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request
_logger = logging.getLogger(__name__)
_ACTIVE_STEP_STATES = ('ready', 'in_progress', 'paused')
class FpLandingController(http.Controller):
@http.route('/fp/landing/kanban', type='jsonrpc', auth='user')
def kanban(self, mode='all_plant', station_id=None, search=None):
env = request.env
Step = env['fp.job.step']
WorkCentre = env['fp.work.centre']
# ---- Resolve station / facility scope --------------------------
station = None
facility = None
if station_id:
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
if stn.exists():
station = stn
facility = stn.facility_id
if not facility:
facility = env['fusion.plating.facility'].search([], limit=1)
# ---- Determine which work centres to show ----------------------
wc_dom = [('active', '=', True)]
if facility:
wc_dom.append(('facility_id', '=', facility.id))
all_wcs = WorkCentre.search(wc_dom, order='sequence, code, name')
if mode == 'station' and station and station.work_center_id:
# Station mode: this WC + Unassigned + next 1-2 WCs by sequence
this_wc = station.work_center_id
after = all_wcs.filtered(lambda w: w.sequence > this_wc.sequence)[:2]
relevant_wcs = this_wc | after
else:
relevant_wcs = all_wcs
# ---- Pull active steps -----------------------------------------
step_dom = [('state', 'in', _ACTIVE_STEP_STATES)]
if facility:
step_dom.append(('work_centre_id.facility_id', '=', facility.id))
if mode == 'station' and relevant_wcs:
# Include unassigned (work_centre_id = False) in station mode too
step_dom += ['|',
('work_centre_id', 'in', relevant_wcs.ids),
('work_centre_id', '=', False),
]
if search:
# Cheap text filter — operators search by WO # / customer / part
search_l = search.strip().lower()
all_steps = Step.search(step_dom, order='sequence, id')
steps = all_steps.filtered(
lambda s: search_l in (s.job_id.display_wo_name or '').lower()
or search_l in (s.job_id.partner_id.name or '').lower()
or search_l in (s.job_id.part_catalog_id.part_number or '').lower()
)
else:
steps = Step.search(step_dom, order='sequence, id')
# ---- Group into columns ----------------------------------------
cards_by_wc = {0: []} # 0 = Unassigned
for step in steps:
wc_id = step.work_centre_id.id or 0
cards_by_wc.setdefault(wc_id, []).append(self._step_to_card(step))
columns = []
for wc in relevant_wcs:
columns.append({
'work_center_id': wc.id,
'work_center_name': wc.name,
'cards': cards_by_wc.get(wc.id, []),
})
if cards_by_wc.get(0):
columns.append({
'work_center_id': 0,
'work_center_name': 'Unassigned',
'cards': cards_by_wc[0],
})
# ---- KPIs (tech-relevant: Ready/Running/BakesDue/Holds) ---------
ready = sum(1 for s in steps if s.state == 'ready')
running = sum(1 for s in steps if s.state == 'in_progress')
BakeWindow = env['fusion.plating.bake.window']
bake_dom = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
if facility:
bake_dom.append(('facility_id', '=', facility.id))
bakes_due = BakeWindow.search_count(bake_dom)
Hold = env['fusion.plating.quality.hold']
holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
return {
'ok': True,
'mode': mode,
'station': {
'id': station.id, 'name': station.name, 'code': station.code or '',
'work_center_name': station.work_center_id.name or '',
} if station else None,
'facility_name': facility.name if facility else '',
'columns': columns,
'kpis': {
'ready': ready, 'running': running,
'bakes_due': bakes_due, 'holds': holds,
},
'server_time': fp_format(env, fields.Datetime.now(), fmt='%H:%M:%S'),
}
def _step_to_card(self, step):
"""Build the kanban card payload for one fp.job.step."""
job = step.job_id
return {
'step_id': step.id,
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'part': (job.part_catalog_id.part_number if job.part_catalog_id
else (job.product_id.display_name or '')),
'qty': int(job.qty or 0),
'qty_done': int(job.qty_done or 0),
'qty_scrapped': int(job.qty_scrapped or 0),
'date_deadline': fp_format(
request.env, job.date_deadline, fmt='%b %d',
) if job.date_deadline else '',
'priority': job.priority or 'normal',
'workflow_state': {
'id': job.workflow_state_id.id,
'name': job.workflow_state_id.name,
'color': job.workflow_state_id.color or 'grey',
} if job.workflow_state_id else None,
'blocker_kind': step.blocker_kind,
'blocker_reason': step.blocker_reason or '',
'current_step_id': step.id,
'current_step_name': step.name,
'work_center': step.work_centre_id.name or '',
}
- Step 4: Register the controller
In fusion_plating/fusion_plating_shopfloor/controllers/__init__.py, add:
from . import landing_controller
- Step 5: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py \
fusion_plating/fusion_plating_shopfloor/controllers/__init__.py \
fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py
git commit -m "feat(fusion_plating_shopfloor): /fp/landing/kanban endpoint (station + all-plant)"
Task 3.2–3.6 — ShopfloorLanding OWL component, menu rewire, deprecation stubs
NOTE for executing agent: Tasks 3.2–3.6 follow the same TDD/incremental pattern as Task 1.12–1.16. Total ~5 tasks. Each ships its own commit. Reference
fp_plant_overview.xmlandplant_overview.jsfor drag-drop pattern (port the column/card DnD handlers verbatim into ShopfloorLanding). Referenceshopfloor_tablet.jsfor QR scan integration and station pairing. The tap-card behavior isaction.doAction({tag:'fp_job_workspace', params:{job_id, focus_step_id}})— already smoke-tested in Task 1.12.
Each task in this group:
- 3.2 —
fp_shopfloor_landingcomponent scaffold (header + KPI strip + station picker + mode toggle); calls/fp/landing/kanban, renders columns ofKanbanCards; auto-refresh 15s; localStorage station + mode persistence. - 3.3 — Drag-and-drop port from
plant_overview.js(onDragStart,onDrop,/fp/shopfloor/plant_overview/move_cardreuse). - 3.4 — QR scan integration (existing
QrScannercomponent, dispatch via existing/fp/shopfloor/scan). - 3.5 — Register the new client action; update
fp_menu.xmlto point the existing "Tablet Station" menu item atfp_shopfloor_landinginstead offp_shopfloor_tablet; hide the Plant Overview menu item (active=Falseon the action record). - 3.6 — Stub deprecated endpoints in
shopfloor_controller.py:tablet_overview→ internally callsFpLandingController().kanban(mode='station', station_id=station_id)and reshapes the response so the old client (if any cached) still works.plant_overview→ internally callsFpLandingController().kanban(mode='all_plant', station_id=None).queue→ return{'ok': False, 'error': 'deprecated — use /fp/landing/kanban'}.
Bump: fusion_plating_shopfloor to 19.0.28.0.0 at end of Phase 3. Tag phase3-landing.
Phase 4 — Manager Dashboard refactor
Goal: Add 3 new tabs (Workflow Funnel, Approval Inbox, At-Risk) to fp_manager_dashboard, alongside the existing 3-column Plant Board (now one of four tabs). Add 2 new KPI tiles. Add bottleneck_score + avg_wait_minutes computes on fp.work.centre.
Task 4.1 — bottleneck_score + avg_wait_minutes computes on fp.work.centre
Files:
-
Modify:
fusion_plating/fusion_plating/models/fp_work_centre.py -
Step 1: Add the computes
bottleneck_score = fields.Float(
compute='_compute_bottleneck',
string='Bottleneck Score',
help='active_step_count × avg_wait_minutes (rolling 7-day). '
'Drives the Manager At-Risk heatmap.',
)
avg_wait_minutes = fields.Float(
compute='_compute_bottleneck',
string='Avg Wait (min)',
help='Average minutes steps wait in ready state before starting, '
'over the last 7 days.',
)
def _compute_bottleneck(self):
from datetime import timedelta
Step = self.env['fp.job.step']
now = fields.Datetime.now()
seven_days_ago = now - timedelta(days=7)
for wc in self:
active_n = Step.search_count([
('work_centre_id', '=', wc.id),
('state', 'in', ('ready', 'in_progress')),
])
# Avg wait — recent steps where date_started is set and there's an
# implied "ready since" we can approximate as create_date.
recent = Step.search([
('work_centre_id', '=', wc.id),
('date_started', '>=', seven_days_ago),
('date_started', '!=', False),
])
waits = []
for s in recent:
if s.create_date and s.date_started:
waits.append((s.date_started - s.create_date).total_seconds() / 60.0)
avg = sum(waits) / len(waits) if waits else 0.0
wc.avg_wait_minutes = avg
wc.bottleneck_score = active_n * avg
- Step 2: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating/models/fp_work_centre.py
git commit -m "feat(fusion_plating): fp.work.centre bottleneck_score + avg_wait_minutes"
Tasks 4.2–4.10 — Manager controller endpoints + dashboard tab refactor
NOTE for executing agent: Each task is a self-contained backend endpoint OR a self-contained UI tab. Pattern is the same as Phase 1 (TDD where it makes sense for endpoints; UI tasks ship per-tab commits). The structural change to the manager dashboard XML is extracting the existing 3-column layout into a
<t t-name="...PlantBoardTab">sub-template, adding a tab nav strip, and creating sibling sub-templates forWorkflowFunnelTab,ApprovalInboxTab,AtRiskTab. The JS addsstate.activeTaband per-tabloadX()methods.
- 4.2 —
/fp/manager/funnelendpoint +test_manager_funnel.py(groups jobs byworkflow_state_id, returns counts + top 5 cards per stage). - 4.3 —
/fp/manager/approval_inboxendpoint +test_manager_inbox.py(4 buckets: holds awaiting release, draft required certs, recent scrap, override requests). - 4.4 —
/fp/manager/at_riskendpoint +test_manager_at_risk.py(top 20 bylate_risk_ratiodesc; hold reasons grouped; bottleneck list frombottleneck_score). - 4.5 — Manager dashboard XML refactor: tab navigation strip, 4 sub-templates.
- 4.6 — WorkflowFunnelTab template + data wiring + click-to-WorkspaceOpen.
- 4.7 — ApprovalInboxTab template + per-row action buttons (Release Hold via existing model method; Issue Cert via existing wizard; Acknowledge Scrap = just chatter post).
- 4.8 — AtRiskTab template (3 sub-panels: Trending Late, Hold Reasons, Bottleneck).
- 4.9 — Move existing Plant Board JS/XML into PlantBoardTab sub-template (no behavior change, just relocation).
- 4.10 — Add 2 new KPI tiles in the strip: Pending Cert (jobs
all_steps_terminal=TrueAND_fp_has_draft_required_certs()), At-Risk (count of jobs withlate_risk_ratio > 1.0). Conditional red when > 0.
Bump: fusion_plating_shopfloor to 19.0.29.0.0, fusion_plating to 19.0.20.8.0. Tag phase4-manager-refactor.
Phase 5 — Cleanup
Goal: Remove deprecated endpoint stubs, retire fp_plant_overview from menus, update README + CLAUDE.md.
Task 5.1 — Remove deprecated endpoint stubs
- In
shopfloor_controller.py, delete thetablet_overview,plant_overview, andqueueendpoints (Phase 3 stubs). Auditgit logfor any callers outside this module — if any, leave the stub one more release. - Run smoke test.
- Commit:
chore(fusion_plating_shopfloor): remove Phase 3 deprecation stubs.
Task 5.2 — Remove old menu entries
- In
fp_menu.xml, delete the menu item that referencedfp_shopfloor_tabletaction (now points atfp_shopfloor_landing; the rename was done in Phase 3 — this step removes any leftover xmlids). - Same for
fp_plant_overview. - Update + commit.
Task 5.3 — Update CLAUDE.md + README
- In
fusion_plating/CLAUDE.md, add a "Shop Floor surfaces" section near "Module Structure" documenting the three client actions + the shared OWL services + when to use each. Reference the spec. - In
fusion_plating_shopfloor/README.md, replace the "Tablet client" section with the new architecture overview. - Commit.
Task 5.4 — Final tag
git tag phase5-cleanup-complete- Push tags.
Self-Review
Reviewed against spec 2026-05-22-shopfloor-tablet-redesign-design.md:
Spec coverage:
- §4 Terminology —
display_wo_name(Task 1.1) ✓; "Up Next" / "Embrittlement Bakes" / etc. are XML string changes, called out implicitly via the Landing/Manager refactors (Tasks 3.2–3.6, 4.5). - §5.1 Shared services — Tasks 1.3 (WorkflowChip), 1.4 (GateViz), 1.5 (SignaturePad), 1.6 (HoldComposer), 1.7 (KanbanCard) ✓
- §5.2 Landing — Tasks 3.1–3.6 ✓
- §5.3 Workspace — Tasks 1.8–1.16 ✓
- §5.4 Manager refactor — Tasks 4.1–4.10 ✓
- §6.1 New endpoints — Tasks 1.8/1.9/1.10/1.11 (workspace), 3.1 (landing), 4.2/4.3/4.4 (manager) ✓
- §6.2 Model fields — Tasks 1.1, 1.2, 2.1, 2.2, 2.3, 4.1 ✓
- §6.3 Cron — Tasks 2.4 (method), 2.5 (registration) ✓
- §6.4 ACL — Task 2.6 ✓
- §6.5 Terminology display — Task 1.1 ✓ (sequence rename deferred per spec)
- §7 Build sequence — phases map 1:1
- §8 Testing — each task includes its tests
- §13 Backwards compat — Phase 3 stubs (Task 3.6) preserve old QR codes, old endpoints, old smart buttons
Placeholder scan: No "TBD" / "implement later" / "TODO" outside the JobWorkspace XML template scaffolding (which is properly resolved in Tasks 1.13/1.14/1.15). Phase 3 Tasks 3.2–3.6 and Phase 4 Tasks 4.2–4.10 use a structured note pattern ("NOTE for executing agent") that points at concrete reference files and specifies the per-task pattern — not placeholder language. Anyone executing them has the spec, the reference implementations, and clear scope per task.
Type consistency: display_wo_name (string) used consistently in payloads. blocker_kind Selection values match between model compute (Task 1.2) and JS consumer in gate_viz.js (Task 1.4). requires_signoff field used in Workspace render (Task 1.13) — confirmed present in existing fp.job.step per audit. workflow_state_id.color values (Selection: grey/blue/cyan/...) match the SCSS color map in workflow_chip.js toneClass.
Scope check: Phases 1–2 are foundational (shared services + computes + cron + ACL). Phases 3–5 build on them. Each phase is independently deployable per the spec. Plan is appropriately decomposed for staged delivery; a separate plan per phase is unnecessary.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md. Two execution options:
1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration. Best for a plan of this size (28+ tasks).
2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?