refactor(shopfloor,jobs): consolidate operator UI into shopfloor
Removes the parallel OWL/controller stack I built in fusion_plating_jobs (job_process_tree, job_plant_overview, job_manager_dashboard, job_tablet, job_*.scss, plus parallel controllers and action XML files). Refactors the existing fusion_plating_shopfloor components in place to bind to fp.job / fp.job.step instead of mrp.production / mrp.workorder. End state: - ONE operator UI module (shopfloor) instead of two parallel ones - Existing token system (_fp_shopfloor_tokens.scss) reused as designed - no duplicate jobs tokens - Existing /fp/shopfloor/* RPC URLs preserved (no integration breakage); workorder_id kwargs accepted as legacy aliases for step_id / job_id so older tablet clients keep working - Existing visual designs preserved - only the data layer underneath changed - Process Tree button on fp.job form now points at fusion_plating_shopfloor's fp_process_tree client action - Bake Windows / First-Piece Gates / Bake Oven / Operator Queue models stay where they were - legacy_menu_hide.xml trimmed: only the bridge_mrp Production Priorities entry remains; the 3 shopfloor menus (Manager Desk, Plant Overview, Tablet Station) are now visible (the canonical native consoles) Manifests: - fusion_plating_jobs 19.0.3.1.0 -> 19.0.4.0.0 (consolidation bump, no more bundled JS/SCSS, only job_scan controller retained) - fusion_plating_shopfloor 19.0.14.4.0 -> 19.0.15.0.0 (asset bundle cache-bust + significant controller refactor) Tests pass on entech: 0 failed, 0 errors of 41 tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Consolidated 2026-04-24: the parallel OWL/controller stack was
|
||||
# removed. job_scan is the only controller retained — it powers the
|
||||
# QR-sticker scan redirect for fp.job records.
|
||||
from . import job_scan
|
||||
from . import process_tree
|
||||
from . import plant_overview
|
||||
from . import manager_dashboard
|
||||
from . import tablet
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# /fp/jobs/manager_dashboard — JSON endpoint powering the native-job
|
||||
# Manager Dashboard. Returns a flat list of in-flight fp.job rows
|
||||
# with progress / current-step / deadline info, plus state-count
|
||||
# pills for the filter bar at the top of the dashboard.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpJobsManagerDashboardController(http.Controller):
|
||||
|
||||
@http.route('/fp/jobs/manager_dashboard', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_manager_dashboard(self, state=None, **kwargs):
|
||||
env = request.env
|
||||
Job = env['fp.job']
|
||||
|
||||
# Default view: jobs that need triage. Specifying state=<value>
|
||||
# narrows to that one bucket; state='all' opens the floodgates.
|
||||
if state and state != 'all':
|
||||
domain = [('state', '=', state)]
|
||||
elif state == 'all':
|
||||
domain = []
|
||||
else:
|
||||
domain = [('state', 'in', ('confirmed', 'in_progress', 'on_hold'))]
|
||||
|
||||
jobs = Job.search(
|
||||
domain,
|
||||
order='priority desc, date_deadline asc, id desc',
|
||||
limit=200,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for job in jobs:
|
||||
rows.append({
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'partner': job.partner_id.name or '',
|
||||
'qty': job.qty,
|
||||
'state': job.state,
|
||||
'priority': job.priority,
|
||||
'date_deadline': (
|
||||
job.date_deadline.isoformat()
|
||||
if job.date_deadline else None
|
||||
),
|
||||
'current_step': (
|
||||
job.current_step_id.name
|
||||
if job.current_step_id else None
|
||||
),
|
||||
'current_location': job.current_location,
|
||||
'progress_pct': job.step_progress_pct,
|
||||
'step_done': job.step_done_count,
|
||||
'step_total': job.step_count,
|
||||
'recipe': job.recipe_id.name if job.recipe_id else None,
|
||||
})
|
||||
|
||||
# State-count pills for the filter bar — let the dashboard show
|
||||
# the manager how big each bucket is at a glance.
|
||||
counts = {}
|
||||
for s in ('confirmed', 'in_progress', 'on_hold', 'done'):
|
||||
counts[s] = Job.search_count([('state', '=', s)])
|
||||
|
||||
return {'rows': rows, 'counts': counts}
|
||||
@@ -1,98 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# /fp/jobs/plant_overview — JSON endpoints powering the native-job
|
||||
# Plant Overview kanban (operator triage view, Phase 6 of the native
|
||||
# job migration). Columns are fp.work.centre rows; cards are
|
||||
# fp.job.step rows in ready / in_progress / paused state. Drag a
|
||||
# card across columns to reassign that step's work_centre_id.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpJobsPlantOverviewController(http.Controller):
|
||||
|
||||
@http.route('/fp/jobs/plant_overview', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_plant_overview(self, facility_id=None, **kwargs):
|
||||
env = request.env
|
||||
WorkCentre = env['fp.work.centre']
|
||||
Step = env['fp.job.step']
|
||||
|
||||
wc_domain = [('active', '=', True)]
|
||||
if facility_id:
|
||||
wc_domain.append(('facility_id', '=', int(facility_id)))
|
||||
centres = WorkCentre.search(wc_domain, order='sequence, code, name')
|
||||
|
||||
# Active steps grouped by work_centre. We pull paused too so a
|
||||
# manager can see — and re-route — a step that's been paused
|
||||
# on the wrong line.
|
||||
step_domain = [('state', 'in', ('ready', 'in_progress', 'paused'))]
|
||||
if facility_id:
|
||||
step_domain.append(('facility_id', '=', int(facility_id)))
|
||||
active_steps = Step.search(step_domain, order='job_id, sequence')
|
||||
|
||||
cards_by_wc = {}
|
||||
for step in active_steps:
|
||||
wc_id = step.work_centre_id.id or 0
|
||||
cards_by_wc.setdefault(wc_id, []).append({
|
||||
'id': step.id,
|
||||
'name': step.name,
|
||||
'state': step.state,
|
||||
'job_id': step.job_id.id,
|
||||
'job_name': step.job_id.name,
|
||||
'partner': step.job_id.partner_id.name or '',
|
||||
'sequence': step.sequence,
|
||||
'kind': step.kind,
|
||||
'duration_expected': step.duration_expected,
|
||||
'duration_actual': step.duration_actual,
|
||||
'assigned_user': (
|
||||
step.assigned_user_id.name
|
||||
if step.assigned_user_id else None
|
||||
),
|
||||
'thickness_target': step.thickness_target,
|
||||
'thickness_uom': step.thickness_uom,
|
||||
'priority': step.job_id.priority,
|
||||
})
|
||||
|
||||
columns = []
|
||||
for wc in centres:
|
||||
columns.append({
|
||||
'id': wc.id,
|
||||
'code': wc.code,
|
||||
'name': wc.name,
|
||||
'kind': wc.kind,
|
||||
'facility': wc.facility_id.name if wc.facility_id else None,
|
||||
'cards': cards_by_wc.get(wc.id, []),
|
||||
})
|
||||
# An "Unassigned" pseudo-column for steps without a work centre —
|
||||
# only rendered when there's something to show, so empty plants
|
||||
# don't pick up a stray column.
|
||||
if cards_by_wc.get(0):
|
||||
columns.append({
|
||||
'id': 0,
|
||||
'code': '—',
|
||||
'name': 'Unassigned',
|
||||
'kind': 'other',
|
||||
'facility': None,
|
||||
'cards': cards_by_wc[0],
|
||||
})
|
||||
|
||||
return {'columns': columns}
|
||||
|
||||
@http.route('/fp/jobs/plant_overview/move_card', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_move_card(self, step_id, work_centre_id, **kwargs):
|
||||
"""Reassign a step to a different work centre.
|
||||
|
||||
work_centre_id == 0 (or falsy) clears the work centre — the card
|
||||
will land in the Unassigned pseudo-column on the next refresh.
|
||||
"""
|
||||
env = request.env
|
||||
Step = env['fp.job.step']
|
||||
step = Step.browse(int(step_id)).exists()
|
||||
if not step:
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
wc_id = int(work_centre_id) if work_centre_id else False
|
||||
step.work_centre_id = wc_id
|
||||
return {'ok': True}
|
||||
@@ -1,48 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# /fp/jobs/process_tree — JSON endpoint that returns the recipe tree
|
||||
# for a given fp.job, with each node tagged by the matching
|
||||
# fp.job.step (if any) and its current state.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpJobProcessTreeController(http.Controller):
|
||||
|
||||
@http.route('/fp/jobs/process_tree', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_process_tree(self, job_id, **kwargs):
|
||||
Job = request.env['fp.job']
|
||||
job = Job.browse(int(job_id)).exists()
|
||||
if not job:
|
||||
return {'error': 'Job not found'}
|
||||
|
||||
# Map recipe_node_id -> step
|
||||
step_by_node = {s.recipe_node_id.id: s for s in job.step_ids if s.recipe_node_id}
|
||||
|
||||
def serialize(node):
|
||||
step = step_by_node.get(node.id)
|
||||
return {
|
||||
'id': node.id,
|
||||
'name': node.name,
|
||||
'node_type': node.node_type,
|
||||
'sequence': node.sequence,
|
||||
'step_id': step.id if step else None,
|
||||
'step_state': step.state if step else None,
|
||||
'step_assigned_user': step.assigned_user_id.name if step and step.assigned_user_id else None,
|
||||
'duration_expected': step.duration_expected if step else node.estimated_duration,
|
||||
'duration_actual': step.duration_actual if step else 0.0,
|
||||
'children': [serialize(c) for c in node.child_ids.sorted('sequence')],
|
||||
}
|
||||
|
||||
return {
|
||||
'job_name': job.name,
|
||||
'partner': job.partner_id.name,
|
||||
'state': job.state,
|
||||
'qty': job.qty,
|
||||
'recipe_name': job.recipe_id.name if job.recipe_id else '',
|
||||
'progress_pct': job.step_progress_pct,
|
||||
'tree': serialize(job.recipe_id) if job.recipe_id else None,
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# /fp/jobs/tablet/* — JSON-RPC endpoints powering the native-job
|
||||
# Tablet Station (Phase 6 of the native job migration). Operator-
|
||||
# facing touchscreen UI for starting/finishing fp.job.step rows.
|
||||
#
|
||||
# Endpoints:
|
||||
# POST /fp/jobs/tablet/jobs -> active jobs the operator can pick
|
||||
# POST /fp/jobs/tablet/job_detail -> job header + ordered step list
|
||||
# POST /fp/jobs/tablet/start_step -> calls fp.job.step.button_start
|
||||
# POST /fp/jobs/tablet/finish_step -> calls fp.job.step.button_finish
|
||||
#
|
||||
# All write paths funnel through the model's button_start / button_finish
|
||||
# methods so the audit / timelog / duration_actual roll-up logic from
|
||||
# Phase 1 still applies.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpJobsTabletController(http.Controller):
|
||||
|
||||
@http.route('/fp/jobs/tablet/jobs', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_tablet_jobs(self, facility_id=None, **kwargs):
|
||||
"""Active jobs the operator can pick from."""
|
||||
env = request.env
|
||||
Job = env['fp.job']
|
||||
domain = [('state', 'in', ('confirmed', 'in_progress'))]
|
||||
if facility_id:
|
||||
domain.append(('facility_id', '=', int(facility_id)))
|
||||
jobs = Job.search(
|
||||
domain,
|
||||
order='priority desc, date_deadline asc, id desc',
|
||||
limit=50,
|
||||
)
|
||||
return {
|
||||
'jobs': [{
|
||||
'id': j.id,
|
||||
'name': j.name,
|
||||
'partner': j.partner_id.name or '',
|
||||
'qty': j.qty,
|
||||
'progress_pct': j.step_progress_pct,
|
||||
'state': j.state,
|
||||
'priority': j.priority,
|
||||
'current_step': (
|
||||
j.current_step_id.name if j.current_step_id else None
|
||||
),
|
||||
'deadline': (
|
||||
j.date_deadline.isoformat() if j.date_deadline else None
|
||||
),
|
||||
} for j in jobs],
|
||||
}
|
||||
|
||||
@http.route('/fp/jobs/tablet/job_detail', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_tablet_job_detail(self, job_id, **kwargs):
|
||||
"""Job header + ordered step list for the detail panel."""
|
||||
env = request.env
|
||||
Job = env['fp.job']
|
||||
job = Job.browse(int(job_id)).exists()
|
||||
if not job:
|
||||
return {'error': 'Job not found'}
|
||||
steps = []
|
||||
for step in job.step_ids.sorted('sequence'):
|
||||
steps.append({
|
||||
'id': step.id,
|
||||
'name': step.name,
|
||||
'sequence': step.sequence,
|
||||
'state': step.state,
|
||||
'kind': step.kind,
|
||||
'work_centre': (
|
||||
step.work_centre_id.name if step.work_centre_id else None
|
||||
),
|
||||
'duration_expected': step.duration_expected,
|
||||
'duration_actual': step.duration_actual,
|
||||
'thickness_target': step.thickness_target,
|
||||
'thickness_uom': step.thickness_uom,
|
||||
'assigned_user': (
|
||||
step.assigned_user_id.name
|
||||
if step.assigned_user_id else None
|
||||
),
|
||||
'date_started': (
|
||||
step.date_started.isoformat() if step.date_started else None
|
||||
),
|
||||
'date_finished': (
|
||||
step.date_finished.isoformat() if step.date_finished else None
|
||||
),
|
||||
})
|
||||
return {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'partner': job.partner_id.name or '',
|
||||
'qty': job.qty,
|
||||
'state': job.state,
|
||||
'priority': job.priority,
|
||||
'recipe': job.recipe_id.name if job.recipe_id else None,
|
||||
'progress_pct': job.step_progress_pct,
|
||||
'step_done': job.step_done_count,
|
||||
'step_total': job.step_count,
|
||||
'steps': steps,
|
||||
}
|
||||
|
||||
@http.route('/fp/jobs/tablet/step_detail', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_tablet_step_detail(self, step_id, **kwargs):
|
||||
"""Step detail panel — used to refresh after button_start /
|
||||
button_finish so the timelog history pulls in the new row.
|
||||
"""
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id)).exists()
|
||||
if not step:
|
||||
return {'error': 'Step not found'}
|
||||
timelogs = []
|
||||
for log in step.time_log_ids.sorted('date_started', reverse=True):
|
||||
timelogs.append({
|
||||
'id': log.id,
|
||||
'user': log.user_id.name or '',
|
||||
'date_started': (
|
||||
log.date_started.isoformat() if log.date_started else None
|
||||
),
|
||||
'date_finished': (
|
||||
log.date_finished.isoformat() if log.date_finished else None
|
||||
),
|
||||
'duration_minutes': log.duration_minutes,
|
||||
})
|
||||
return {
|
||||
'id': step.id,
|
||||
'name': step.name,
|
||||
'sequence': step.sequence,
|
||||
'state': step.state,
|
||||
'kind': step.kind,
|
||||
'work_centre': (
|
||||
step.work_centre_id.name if step.work_centre_id else None
|
||||
),
|
||||
'duration_expected': step.duration_expected,
|
||||
'duration_actual': step.duration_actual,
|
||||
'thickness_target': step.thickness_target,
|
||||
'thickness_uom': step.thickness_uom,
|
||||
'assigned_user': (
|
||||
step.assigned_user_id.name
|
||||
if step.assigned_user_id else None
|
||||
),
|
||||
'date_started': (
|
||||
step.date_started.isoformat() if step.date_started else None
|
||||
),
|
||||
'date_finished': (
|
||||
step.date_finished.isoformat() if step.date_finished else None
|
||||
),
|
||||
'instructions': step.instructions or '',
|
||||
'timelogs': timelogs,
|
||||
}
|
||||
|
||||
@http.route('/fp/jobs/tablet/start_step', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_tablet_start_step(self, step_id, **kwargs):
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id)).exists()
|
||||
if not step:
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
try:
|
||||
step.button_start()
|
||||
return {
|
||||
'ok': True,
|
||||
'state': step.state,
|
||||
'date_started': (
|
||||
step.date_started.isoformat() if step.date_started else None
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
|
||||
@http.route('/fp/jobs/tablet/finish_step', type='jsonrpc', auth='user', website=False)
|
||||
def fp_jobs_tablet_finish_step(self, step_id, **kwargs):
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id)).exists()
|
||||
if not step:
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
try:
|
||||
step.button_finish()
|
||||
return {
|
||||
'ok': True,
|
||||
'state': step.state,
|
||||
'duration_actual': step.duration_actual,
|
||||
'date_finished': (
|
||||
step.date_finished.isoformat() if step.date_finished else None
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': str(e)}
|
||||
Reference in New Issue
Block a user