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:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.14.4.0',
|
||||
'version': '19.0.15.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
# 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 Manager Dashboard (client action)."""
|
||||
"""JSON-RPC endpoints for the Manager Dashboard (client action).
|
||||
|
||||
Native fp.job / fp.job.step edition (consolidated 2026-04-24). All
|
||||
endpoint URLs are preserved (`/fp/manager/*`); the underlying data
|
||||
layer is now fp.job + fp.job.step.
|
||||
|
||||
Manager Desk ergonomics:
|
||||
- Column 1 ("Needs a Worker") = jobs that have at least one step
|
||||
missing the bits a manager has to set before an operator can tap
|
||||
Start (worker, work centre, kind-specific equipment).
|
||||
- Column 2 ("In Progress") = jobs whose steps are release-ready or
|
||||
actively running.
|
||||
- Column 3 ("Team") = operators with their open / in-progress counts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -15,26 +28,45 @@ from odoo.http import request
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpManagerDashboardController(http.Controller):
|
||||
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
|
||||
# --- helpers -----------------------------------------------------------------
|
||||
|
||||
All endpoints require the user to be a manager or above. The UI locks
|
||||
the menu behind group_fusion_plating_manager.
|
||||
"""
|
||||
_NEG_JOB_STATES = ('done', 'cancelled')
|
||||
_ACTIVE_JOB_STATES = ('confirmed', 'in_progress', 'on_hold')
|
||||
|
||||
# A step needs an operator and (for wet/bake/mask) the right equipment
|
||||
# before the operator can tap Start. Mirrors the legacy
|
||||
# x_fc_is_release_ready compute on mrp.workorder.
|
||||
def _step_release_readiness(step):
|
||||
"""Return (is_release_ready, missing_str) for a fp.job.step."""
|
||||
missing = []
|
||||
if not step.assigned_user_id:
|
||||
missing.append('worker')
|
||||
if not step.work_centre_id:
|
||||
missing.append('work centre')
|
||||
if step.kind == 'wet':
|
||||
if not step.bath_id:
|
||||
missing.append('bath')
|
||||
if not step.tank_id:
|
||||
missing.append('tank')
|
||||
elif step.kind == 'rack':
|
||||
if not step.rack_id:
|
||||
missing.append('rack')
|
||||
return (not missing, ', '.join(missing))
|
||||
|
||||
|
||||
def _priority_int(priority):
|
||||
"""fp.job.priority → int 0/1/2 (parallel of legacy x_fc_priority)."""
|
||||
return {'rush': 2, 'high': 1, 'normal': 0, 'low': 0}.get(priority, 0)
|
||||
|
||||
|
||||
class FpManagerDashboardController(http.Controller):
|
||||
"""Manager-level view: unassigned jobs, in-progress jobs, team workload."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overview snapshot — used on initial load + 30s auto-refresh
|
||||
# Overview snapshot — used on initial load + 8s auto-refresh
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
|
||||
def overview(self, facility_id=None, known_hash=None):
|
||||
"""Build the manager dashboard payload.
|
||||
|
||||
`known_hash`: if the client sends back the hash of its last
|
||||
overview, we compare and return `{'unchanged': True}` when
|
||||
nothing has moved. Keeps the UI flicker-free between polls
|
||||
while still catching every shop-floor change within a few
|
||||
seconds.
|
||||
"""
|
||||
try:
|
||||
return self._overview_payload(facility_id, known_hash)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -43,187 +75,122 @@ class FpManagerDashboardController(http.Controller):
|
||||
|
||||
def _overview_payload(self, facility_id, known_hash):
|
||||
env = request.env
|
||||
MrpWO = env.get('mrp.workorder')
|
||||
Production = env.get('mrp.production')
|
||||
if MrpWO is None or Production is None:
|
||||
return {
|
||||
'ok': True,
|
||||
'kpis': {'unassigned_wos': 0, 'active_wos': 0,
|
||||
'ready_to_ship_mos': 0, 'pending_accept_sos': 0},
|
||||
'unassigned': [], 'active': [], 'team': [],
|
||||
'operators': [], 'tanks': [],
|
||||
'user_name': env.user.name,
|
||||
'mrp_missing': True,
|
||||
'payload_hash': '',
|
||||
}
|
||||
# The assignment field lives in fusion_plating_bridge_mrp. If it's
|
||||
# missing, the dashboard still renders but the worker pickers are
|
||||
# effectively read-only.
|
||||
has_assign = 'x_fc_assigned_user_id' in MrpWO._fields
|
||||
Job = env['fp.job']
|
||||
Step = env['fp.job.step']
|
||||
|
||||
# ---- Column 1: Unassigned ("Setup Pending") --------------------
|
||||
# A WO stays here until the manager has set EVERY field
|
||||
# button_start would block on (operator + per-kind equipment).
|
||||
# Without this, picking a worker would auto-jump the row to
|
||||
# "In Progress" before bath/tank/oven/rack/material are set.
|
||||
# We compute release-readiness in Python after the SQL search
|
||||
# because x_fc_is_release_ready is a non-stored compute.
|
||||
ACTIVE_NEG_STATES = ('done', 'cancel')
|
||||
domain_active_states = [('state', 'not in', ACTIVE_NEG_STATES)]
|
||||
# Pull in-flight jobs (confirmed / in_progress / on_hold)
|
||||
domain = [('state', 'in', _ACTIVE_JOB_STATES)]
|
||||
if facility_id:
|
||||
domain_active_states.append(
|
||||
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
|
||||
all_active_wos = MrpWO.search(domain_active_states, order='sequence, id')
|
||||
# Split: not-release-ready → Unassigned/Setup column; rest → In Progress
|
||||
if 'x_fc_is_release_ready' in MrpWO._fields:
|
||||
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready)
|
||||
elif has_assign:
|
||||
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id)
|
||||
else:
|
||||
unassigned_wos = all_active_wos
|
||||
domain.append(('facility_id', '=', int(facility_id)))
|
||||
jobs = Job.search(domain, order='priority desc, date_deadline asc, id desc')
|
||||
|
||||
# Roll up to MO level
|
||||
def _group_by_mo(wos):
|
||||
groups = {}
|
||||
for wo in wos:
|
||||
mo_id = wo.production_id.id
|
||||
groups.setdefault(mo_id, []).append(wo)
|
||||
return groups
|
||||
# Compute release-readiness per step in a single pass
|
||||
all_steps = jobs.mapped('step_ids').filtered(
|
||||
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
|
||||
)
|
||||
readiness_by_step = {}
|
||||
for step in all_steps:
|
||||
ready, missing = _step_release_readiness(step)
|
||||
readiness_by_step[step.id] = (ready, missing)
|
||||
|
||||
# Bucket jobs: "needs a worker" vs "in progress".
|
||||
# A job lands in unassigned iff at least one of its open steps
|
||||
# is NOT release-ready. Otherwise it goes to in_progress.
|
||||
unassigned_jobs = jobs.browse([])
|
||||
active_jobs = jobs.browse([])
|
||||
for job in jobs:
|
||||
open_steps = job.step_ids.filtered(
|
||||
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
|
||||
)
|
||||
if not open_steps:
|
||||
continue
|
||||
not_ready = any(not readiness_by_step.get(s.id, (False, ''))[0]
|
||||
for s in open_steps)
|
||||
if not_ready:
|
||||
unassigned_jobs |= job
|
||||
else:
|
||||
active_jobs |= job
|
||||
|
||||
def _job_card(job, only_open=True):
|
||||
partner = job.partner_id
|
||||
steps_iter = job.step_ids
|
||||
if only_open:
|
||||
steps_iter = steps_iter.filtered(
|
||||
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
|
||||
)
|
||||
steps_iter = steps_iter.sorted('sequence')
|
||||
|
||||
wo_rows = []
|
||||
for s in steps_iter:
|
||||
ready, missing = readiness_by_step.get(s.id, (False, ''))
|
||||
wo_rows.append({
|
||||
'id': s.id,
|
||||
'name': s.name or '',
|
||||
'workcenter': s.work_centre_id.name or '',
|
||||
'state': s.state,
|
||||
'sequence': s.sequence or 0,
|
||||
'duration_expected': s.duration_expected or 0,
|
||||
'bath': s.bath_id.name or '',
|
||||
'tank': s.tank_id.name or '',
|
||||
'tank_id': s.tank_id.id if s.tank_id else False,
|
||||
'priority': str(_priority_int(job.priority)),
|
||||
'assigned_user_id': s.assigned_user_id.id or False,
|
||||
'assigned_user_name': s.assigned_user_id.name or '',
|
||||
'role_id': False,
|
||||
'role_name': '',
|
||||
'wo_kind': s.kind or 'other',
|
||||
'wo_kind_label': dict(s._fields['kind'].selection).get(
|
||||
s.kind, '',
|
||||
) if s.kind else '',
|
||||
'is_release_ready': ready,
|
||||
'missing_for_release': missing,
|
||||
'oven': '',
|
||||
'rack': s.rack_id.name or '',
|
||||
'masking_material': '',
|
||||
})
|
||||
|
||||
def _mo_card(mo, wos):
|
||||
so_name = mo.origin or ''
|
||||
partner = mo.x_fc_portal_job_id.partner_id if mo.x_fc_portal_job_id else None
|
||||
return {
|
||||
'mo_id': mo.id,
|
||||
'mo_name': mo.name,
|
||||
'so_name': so_name,
|
||||
'mo_id': job.id,
|
||||
'mo_name': job.name or '',
|
||||
'so_name': job.origin or '',
|
||||
'customer': partner.name if partner else '',
|
||||
'product': mo.product_id.display_name if mo.product_id else '',
|
||||
'qty_total': int(mo.product_qty or 0),
|
||||
'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'),
|
||||
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
|
||||
'priority_any': max(
|
||||
[int(w.x_fc_priority or '0') for w in wos] + [0]
|
||||
'product': job.product_id.display_name if job.product_id else '',
|
||||
'qty_total': int(job.qty or 0),
|
||||
'date_planned': fp_format(
|
||||
request.env, job.date_planned_start or job.date_deadline,
|
||||
fmt='%Y-%m-%d',
|
||||
),
|
||||
'current_location': mo.x_fc_current_location or '',
|
||||
'wos': [
|
||||
{
|
||||
'id': w.id,
|
||||
'name': w.display_name or w.name,
|
||||
'workcenter': w.workcenter_id.name or '',
|
||||
'state': w.state,
|
||||
'sequence': w.sequence or 0,
|
||||
'duration_expected': w.duration_expected or 0,
|
||||
'bath': w.x_fc_bath_id.name or '',
|
||||
'tank': w.x_fc_tank_id.name or '',
|
||||
'tank_id': w.x_fc_tank_id.id if w.x_fc_tank_id else False,
|
||||
'priority': w.x_fc_priority or '0',
|
||||
'assigned_user_id': (
|
||||
w.x_fc_assigned_user_id.id
|
||||
if w.x_fc_assigned_user_id else False
|
||||
),
|
||||
'assigned_user_name': (
|
||||
w.x_fc_assigned_user_id.name or ''
|
||||
if w.x_fc_assigned_user_id else ''
|
||||
),
|
||||
# Role required by this step. Used by the
|
||||
# Manager Desk worker dropdown to surface
|
||||
# qualified operators first.
|
||||
'role_id': (
|
||||
w.x_fc_work_role_id.id
|
||||
if w.x_fc_work_role_id else False
|
||||
),
|
||||
'role_name': (
|
||||
w.x_fc_work_role_id.name or ''
|
||||
if w.x_fc_work_role_id else ''
|
||||
),
|
||||
# WO kind classification + what's still missing
|
||||
# before the WO can be released to the operator.
|
||||
# Manager Desk uses these to render the kind
|
||||
# badge and the "needs: bath, tank" hint chips.
|
||||
'wo_kind': (
|
||||
w.x_fc_wo_kind
|
||||
if 'x_fc_wo_kind' in w._fields else 'other'
|
||||
),
|
||||
'wo_kind_label': dict(
|
||||
w._fields['x_fc_wo_kind'].selection
|
||||
).get(w.x_fc_wo_kind, '') if 'x_fc_wo_kind' in w._fields else '',
|
||||
'is_release_ready': (
|
||||
w.x_fc_is_release_ready
|
||||
if 'x_fc_is_release_ready' in w._fields else False
|
||||
),
|
||||
'missing_for_release': (
|
||||
w.x_fc_missing_for_release or ''
|
||||
if 'x_fc_missing_for_release' in w._fields else ''
|
||||
),
|
||||
# Surface oven, rack, masking material so the
|
||||
# manager can see at a glance what's set.
|
||||
'oven': (
|
||||
w.x_fc_oven_id.name or ''
|
||||
if 'x_fc_oven_id' in w._fields and w.x_fc_oven_id
|
||||
else ''
|
||||
),
|
||||
'rack': (
|
||||
w.x_fc_rack_id.name or ''
|
||||
if 'x_fc_rack_id' in w._fields and w.x_fc_rack_id
|
||||
else ''
|
||||
),
|
||||
'masking_material': (
|
||||
dict(w._fields['x_fc_masking_material'].selection).get(
|
||||
w.x_fc_masking_material, ''
|
||||
) if 'x_fc_masking_material' in w._fields and w.x_fc_masking_material
|
||||
else ''
|
||||
),
|
||||
}
|
||||
for w in wos
|
||||
],
|
||||
'recipe': job.recipe_id.name if job.recipe_id else '',
|
||||
'priority_any': _priority_int(job.priority),
|
||||
'current_location': job.current_location or '',
|
||||
'wos': wo_rows,
|
||||
}
|
||||
|
||||
unassigned_cards = []
|
||||
for mo_id, wos in _group_by_mo(unassigned_wos).items():
|
||||
mo = Production.browse(mo_id)
|
||||
unassigned_cards.append(_mo_card(mo, wos))
|
||||
unassigned_cards = [_job_card(j) for j in unassigned_jobs]
|
||||
active_cards = [_job_card(j) for j in active_jobs]
|
||||
|
||||
# ---- Column 2: In Progress -------------------------------------
|
||||
# Release-ready WOs (everything the manager needed to set is
|
||||
# filled in) — operator can tap Start on the iPad.
|
||||
if 'x_fc_is_release_ready' in MrpWO._fields:
|
||||
active_wos = all_active_wos.filtered(lambda w: w.x_fc_is_release_ready)
|
||||
elif has_assign:
|
||||
active_wos = all_active_wos.filtered(lambda w: w.x_fc_assigned_user_id)
|
||||
else:
|
||||
active_wos = MrpWO # empty
|
||||
active_cards = []
|
||||
for mo_id, wos in _group_by_mo(active_wos).items():
|
||||
mo = Production.browse(mo_id)
|
||||
active_cards.append(_mo_card(mo, wos))
|
||||
|
||||
# ---- Column 3: Team (operators + their current load) -----------
|
||||
# ---- Column 3: Team --------------------------------------------
|
||||
operator_group = env.ref(
|
||||
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
|
||||
)
|
||||
team = []
|
||||
if operator_group and has_assign:
|
||||
if operator_group:
|
||||
for user in operator_group.user_ids.sorted('name'):
|
||||
open_wos = MrpWO.search([
|
||||
('x_fc_assigned_user_id', '=', user.id),
|
||||
('state', 'not in', ACTIVE_NEG_STATES),
|
||||
open_steps = Step.search([
|
||||
('assigned_user_id', '=', user.id),
|
||||
('state', 'in', ('ready', 'in_progress', 'paused')),
|
||||
])
|
||||
team.append({
|
||||
'user_id': user.id,
|
||||
'name': user.name,
|
||||
'open_count': len(open_wos),
|
||||
'open_count': len(open_steps),
|
||||
'in_progress_count': len(
|
||||
open_wos.filtered(lambda w: w.state == 'progress')
|
||||
open_steps.filtered(lambda s: s.state == 'in_progress'),
|
||||
),
|
||||
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
|
||||
})
|
||||
|
||||
# ---- Pickers: operators (with presence + role data) -----------
|
||||
# We send richer operator records so the Manager Desk dropdown can
|
||||
# group qualified-and-present at the top, then lead hands, then
|
||||
# off-shift workers (greyed). Without this the manager has to
|
||||
# remember who's clocked in and who can do what.
|
||||
# ---- Operators picker (with presence + role data) --------------
|
||||
clocked_in_user_ids = (
|
||||
env['hr.employee']._fp_clocked_in_user_ids()
|
||||
if 'hr.employee' in env and hasattr(
|
||||
@@ -237,7 +204,11 @@ class FpManagerDashboardController(http.Controller):
|
||||
operators = []
|
||||
for u in operator_users:
|
||||
emp = u.employee_id
|
||||
role_ids = emp.x_fc_work_role_ids.ids if emp else []
|
||||
role_ids = (
|
||||
emp.x_fc_work_role_ids.ids
|
||||
if emp and 'x_fc_work_role_ids' in emp._fields
|
||||
else []
|
||||
)
|
||||
lead_role_ids = (
|
||||
emp.x_fc_lead_hand_role_ids.ids
|
||||
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
|
||||
@@ -250,12 +221,12 @@ class FpManagerDashboardController(http.Controller):
|
||||
'role_ids': role_ids,
|
||||
'lead_hand_role_ids': lead_role_ids,
|
||||
})
|
||||
# Headline counts so the manager sees at-a-glance who's on shift.
|
||||
present_count = sum(1 for o in operators if o['is_clocked_in'])
|
||||
presence = {
|
||||
'clocked_in': present_count,
|
||||
'total': len(operators),
|
||||
}
|
||||
|
||||
Tank = env.get('fusion.plating.tank')
|
||||
tanks = [
|
||||
{
|
||||
@@ -267,36 +238,32 @@ class FpManagerDashboardController(http.Controller):
|
||||
for t in (Tank.search([]) if Tank is not None else [])
|
||||
]
|
||||
|
||||
# KPI summary — every query must use STORED fields only, otherwise
|
||||
# Odoo raises "Cannot convert … to SQL because it is not stored".
|
||||
# x_fc_workflow_stage is computed (non-stored); replicate the
|
||||
# "awaiting assignment" stage directly via its stored antecedents.
|
||||
# ---- KPI summary ----------------------------------------------
|
||||
SO = env['sale.order']
|
||||
so_fields = SO._fields
|
||||
if ('x_fc_receiving_status' in so_fields
|
||||
and 'x_fc_assigned_manager_id' in so_fields):
|
||||
pending_accept_domain = [
|
||||
pending_accept_sos = SO.search_count([
|
||||
('state', '=', 'sale'),
|
||||
('x_fc_receiving_status', '=', 'inspected'),
|
||||
('x_fc_assigned_manager_id', '=', False),
|
||||
]
|
||||
pending_accept_sos = SO.search_count(pending_accept_domain)
|
||||
])
|
||||
else:
|
||||
pending_accept_sos = 0
|
||||
|
||||
# KPI counts derived from the in-memory split we already have —
|
||||
# don't re-query (the release-ready filter is a Python compute,
|
||||
# not a stored column, so SQL search_count can't see it).
|
||||
# Ready-to-ship: jobs that are done but the portal job hasn't
|
||||
# been marked ready_to_ship yet (or no portal mirror at all).
|
||||
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
|
||||
|
||||
kpis = {
|
||||
'unassigned_wos': len(unassigned_wos),
|
||||
'active_wos': len(active_wos),
|
||||
'ready_to_ship_mos': Production.search_count([
|
||||
('state', '=', 'done'),
|
||||
]) if 'x_fc_portal_job_id' not in Production._fields
|
||||
else Production.search_count([
|
||||
('state', '=', 'done'),
|
||||
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
|
||||
]),
|
||||
'unassigned_wos': len(all_steps.filtered(
|
||||
lambda s: not readiness_by_step.get(s.id, (False, ''))[0],
|
||||
)),
|
||||
'active_wos': len(all_steps.filtered(
|
||||
lambda s: readiness_by_step.get(s.id, (False, ''))[0]
|
||||
and s.state in ('ready', 'in_progress'),
|
||||
)),
|
||||
'ready_to_ship_mos': ready_to_ship_jobs,
|
||||
'pending_accept_sos': pending_accept_sos,
|
||||
}
|
||||
|
||||
@@ -325,45 +292,53 @@ class FpManagerDashboardController(http.Controller):
|
||||
return payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Assign a worker to a WO
|
||||
# Assign a worker to a step
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
||||
def assign_worker(self, workorder_id, user_id):
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found.'}
|
||||
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
|
||||
wo.message_post(
|
||||
body=Markup('Worker assigned: <b>%s</b>') % (wo.x_fc_assigned_user_id.name or 'Unassigned'),
|
||||
"""`workorder_id` is the canonical kwarg name from the legacy
|
||||
XML; it now resolves to a fp.job.step id."""
|
||||
step = request.env['fp.job.step'].browse(int(workorder_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.assigned_user_id = int(user_id) if user_id else False
|
||||
step.message_post(
|
||||
body=Markup('Worker assigned: <b>%s</b>') % (
|
||||
step.assigned_user_id.name or 'Unassigned'
|
||||
),
|
||||
)
|
||||
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
|
||||
return {'ok': True, 'user_name': step.assigned_user_id.name or ''}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reassign or swap tank on a WO
|
||||
# Reassign or swap tank on a step
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
|
||||
def assign_tank(self, workorder_id, tank_id):
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found.'}
|
||||
wo.x_fc_tank_id = int(tank_id) if tank_id else False
|
||||
wo.message_post(
|
||||
body=Markup('Tank assigned: <b>%s</b>') % (wo.x_fc_tank_id.name or 'Unassigned'),
|
||||
step = request.env['fp.job.step'].browse(int(workorder_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.tank_id = int(tank_id) if tank_id else False
|
||||
step.message_post(
|
||||
body=Markup('Tank assigned: <b>%s</b>') % (
|
||||
step.tank_id.name or 'Unassigned'
|
||||
),
|
||||
)
|
||||
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
|
||||
return {'ok': True, 'tank_name': step.tank_id.name or ''}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manager takes over a WO (no-show coverage)
|
||||
# Manager takes over a step (no-show coverage)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
|
||||
def take_over(self, workorder_id):
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found.'}
|
||||
step = request.env['fp.job.step'].browse(int(workorder_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
user = request.env.user
|
||||
previous = wo.x_fc_assigned_user_id.name or '—'
|
||||
wo.x_fc_assigned_user_id = user.id
|
||||
wo.message_post(
|
||||
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (user.name, previous),
|
||||
previous = step.assigned_user_id.name or '—'
|
||||
step.assigned_user_id = user.id
|
||||
step.message_post(
|
||||
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (
|
||||
user.name, previous,
|
||||
),
|
||||
)
|
||||
return {'ok': True, 'user_name': user.name}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,10 @@
|
||||
//
|
||||
// Manager-level view: assign workers, swap tanks, cover no-shows, drill
|
||||
// into detail when needed. Three columns: Unassigned / In Progress / Team.
|
||||
//
|
||||
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The
|
||||
// "wo" naming inside payloads is preserved so the existing XML template
|
||||
// keeps rendering — those keys now carry fp.job.step rows under the hood.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
@@ -244,11 +248,11 @@ export class ManagerDashboard extends Component {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: "Operator Queue",
|
||||
res_model: "mrp.workorder",
|
||||
res_model: "fp.job.step",
|
||||
views: [[false, "list"], [false, "form"]],
|
||||
domain: [
|
||||
["x_fc_assigned_user_id", "=", userId],
|
||||
["state", "in", ["ready", "progress", "waiting"]],
|
||||
["assigned_user_id", "=", userId],
|
||||
["state", "in", ["ready", "in_progress", "paused"]],
|
||||
],
|
||||
target: "current",
|
||||
});
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Steelhead-style multi-column kanban showing all active work orders grouped
|
||||
// by work centre / station. Auto-refreshes every 30 s.
|
||||
// Multi-column kanban showing all active fp.job.step rows grouped by
|
||||
// fp.work.centre. Auto-refreshes every 30 s. Drag-drop between columns
|
||||
// reassigns step.work_centre_id.
|
||||
//
|
||||
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The
|
||||
// data layer underneath now points at fp.job.step (cards) / fp.work.centre
|
||||
// (columns); the visual design and RPC URL paths are unchanged.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component: `static template` + `static props = ["*"]`
|
||||
@@ -133,7 +138,7 @@ export class PlantOverview extends Component {
|
||||
onCardDragStart(card, col, ev) {
|
||||
this._draggedCard = {
|
||||
id: card.id,
|
||||
source_model: card.source_model || "mrp.workorder",
|
||||
source_model: card.source_model || "fp.job.step",
|
||||
source_wc_id: col.work_center_id,
|
||||
el: ev.target,
|
||||
};
|
||||
@@ -251,9 +256,10 @@ export class PlantOverview extends Component {
|
||||
if (!card.id) {
|
||||
return;
|
||||
}
|
||||
// Try opening the work order form if MRP is available, otherwise
|
||||
// fall back to bake window or first-piece gate
|
||||
const model = card.source_model || "mrp.workorder";
|
||||
// Cards are fp.job.step rows. The model is overridable per-card
|
||||
// so we keep working if a future card type joins the kanban
|
||||
// (e.g. a quality hold drop-zone column).
|
||||
const model = card.source_model || "fp.job.step";
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
@@ -281,14 +287,21 @@ export class PlantOverview extends Component {
|
||||
|
||||
getStateClass(state) {
|
||||
switch (state) {
|
||||
case "progress":
|
||||
// Native fp.job.step states
|
||||
case "in_progress":
|
||||
return "o_fp_card_progress";
|
||||
case "ready":
|
||||
return "o_fp_card_ready";
|
||||
case "paused":
|
||||
return "o_fp_card_pending";
|
||||
case "done":
|
||||
return "o_fp_card_done";
|
||||
case "pending":
|
||||
return "o_fp_card_pending";
|
||||
// Legacy MRP states still recognised so a server still
|
||||
// serving the old payload renders cleanly.
|
||||
case "progress":
|
||||
return "o_fp_card_progress";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
// Fusion Plating — Process Tree (horizontal hierarchical view)
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Renders the MO's recipe (recipe → sub_process → operation → state) as a
|
||||
// horizontal bracket tree. Cards render dark, identical card style across
|
||||
// Renders an fp.job's recipe (recipe → sub_process → operation → step) as a
|
||||
// horizontal bracket tree. Cards render dark, identical card style across
|
||||
// all depths; connector lines are drawn from CSS so the layout stays in
|
||||
// pure flexbox.
|
||||
//
|
||||
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The data
|
||||
// layer underneath now points at fp.job + fp.job.step, but the visual
|
||||
// design is unchanged.
|
||||
//
|
||||
// Action context:
|
||||
// production_id — required; the MO whose recipe to render
|
||||
// back_workorder_id — optional; if set, the back button returns to
|
||||
// that WO instead of Plant Overview
|
||||
// job_id — required; the fp.job whose recipe to render
|
||||
// production_id — legacy alias for job_id (still accepted)
|
||||
// back_step_id — optional; if set, the back button returns to
|
||||
// that step's form instead of Plant Overview
|
||||
// back_workorder_id — legacy alias for back_step_id
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
@@ -50,19 +56,25 @@ export class ProcessTree extends Component {
|
||||
const a = this.props.action || {};
|
||||
return { ...(a.context || {}), ...(a.params || {}) };
|
||||
}
|
||||
get productionId() { return this._ctx.production_id || null; }
|
||||
get backWorkorderId() { return this._ctx.back_workorder_id || null; }
|
||||
get jobId() {
|
||||
// job_id is the canonical key; production_id is kept as an alias
|
||||
// for legacy callers that still encode that name in their URLs.
|
||||
return this._ctx.job_id || this._ctx.production_id || null;
|
||||
}
|
||||
get backStepId() {
|
||||
return this._ctx.back_step_id || this._ctx.back_workorder_id || null;
|
||||
}
|
||||
get backLabel() {
|
||||
return this.backWorkorderId ? "Back to Work Order" : "Plant Overview";
|
||||
return this.backStepId ? "Back to Step" : "Plant Overview";
|
||||
}
|
||||
|
||||
// ---- Data ---------------------------------------------------------------
|
||||
|
||||
async loadTree() {
|
||||
const prodId = this.productionId;
|
||||
if (!prodId) {
|
||||
const jobId = this.jobId;
|
||||
if (!jobId) {
|
||||
this.notification.add(
|
||||
"No manufacturing order specified for the process tree.",
|
||||
"No job specified for the process tree.",
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
@@ -70,7 +82,7 @@ export class ProcessTree extends Component {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const r = await rpc("/fp/shopfloor/process_tree", {
|
||||
production_id: prodId,
|
||||
job_id: jobId,
|
||||
});
|
||||
if (r) {
|
||||
this.state.productionName = r.production_name || "";
|
||||
@@ -95,25 +107,29 @@ export class ProcessTree extends Component {
|
||||
// ---- Navigation ---------------------------------------------------------
|
||||
|
||||
onNodeClick(node) {
|
||||
if (!node || !node.workorder_id) {
|
||||
// Operation cards with a matching fp.job.step are clickable —
|
||||
// they open the underlying step form. node.workorder_id is the
|
||||
// legacy template key that now carries the step id.
|
||||
const stepId = node && (node.step_id || node.workorder_id);
|
||||
if (!stepId) {
|
||||
return;
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "mrp.workorder",
|
||||
res_id: node.workorder_id,
|
||||
res_model: "fp.job.step",
|
||||
res_id: stepId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
onBack() {
|
||||
const woId = this.backWorkorderId;
|
||||
if (woId) {
|
||||
const stepId = this.backStepId;
|
||||
if (stepId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "mrp.workorder",
|
||||
res_id: parseInt(woId, 10),
|
||||
res_model: "fp.job.step",
|
||||
res_id: parseInt(stepId, 10),
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
@@ -131,7 +147,9 @@ export class ProcessTree extends Component {
|
||||
if (node.state) {
|
||||
parts.push(`o_fp_pt_state_${node.state}`);
|
||||
}
|
||||
if (node.workorder_id) {
|
||||
// step_id is the canonical clickable hint; workorder_id is the
|
||||
// legacy alias. Either one means we have a real step to open.
|
||||
if (node.step_id || node.workorder_id) {
|
||||
parts.push("o_fp_pt_clickable");
|
||||
}
|
||||
if (this.isHighlight(node)) {
|
||||
@@ -140,9 +158,13 @@ export class ProcessTree extends Component {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** A node should pulse-highlight if it is the live position of the MO. */
|
||||
/** Live-position highlight: ready / in_progress / paused. */
|
||||
isHighlight(node) {
|
||||
return node.state === "ready"
|
||||
|| node.state === "in_progress"
|
||||
|| node.state === "paused"
|
||||
// Tolerate the legacy MRP states a node might still
|
||||
// briefly carry on first render (progress/waiting).
|
||||
|| node.state === "progress"
|
||||
|| node.state === "waiting";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). Start /
|
||||
// Finish buttons drive fp.job.step.button_start / button_finish through
|
||||
// the existing /fp/shopfloor/start_wo / stop_wo URLs (now internally
|
||||
// step-bound). The visual design is unchanged.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component using `static template` + `static props = ["*"]`.
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc.
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
<i class="fa fa-user me-1"/>Take Over
|
||||
</button>
|
||||
<button class="btn o_fp_mgr_btn"
|
||||
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
|
||||
t-on-click="() => this.openRecord('fp.job.step', wo.id)">
|
||||
<i class="fa fa-external-link me-1"/>Open WO
|
||||
</button>
|
||||
</div>
|
||||
@@ -250,7 +250,7 @@
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'in_progress' || wo.state === 'progress' ? 'success' : 'info')">
|
||||
<t t-esc="wo.state"/>
|
||||
</span>
|
||||
<button class="btn"
|
||||
@@ -258,7 +258,7 @@
|
||||
Take Over
|
||||
</button>
|
||||
<button class="btn"
|
||||
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
|
||||
t-on-click="() => this.openRecord('fp.job.step', wo.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
t-if="node.qty_total"
|
||||
t-esc="qtyLabel(node)"/>
|
||||
<i class="o_fp_pt_card_open fa fa-external-link"
|
||||
t-if="node.workorder_id"/>
|
||||
t-if="node.step_id or node.workorder_id"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
Active: <strong t-esc="state.overview.active_wo.name"/>
|
||||
</div>
|
||||
<div class="o_fp_active_wo_meta">
|
||||
MO <t t-esc="state.overview.active_wo.mo_name"/>
|
||||
Job <t t-esc="state.overview.active_wo.mo_name"/>
|
||||
· <t t-esc="state.overview.active_wo.product_name"/>
|
||||
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
|
||||
<t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
|
||||
@@ -97,8 +97,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="o_fp_big_button"
|
||||
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
|
||||
Open WO
|
||||
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
|
||||
Open Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user