feat(fusion_plating_shopfloor): workspace_controller — 4 endpoints + tests
Plan tasks P1.8 through P1.11 batched into one commit (local tests not
run between them; entech is the verification env).
POST /fp/workspace/load — full payload for one fp.job
POST /fp/workspace/hold — quality.hold create with photo
POST /fp/workspace/sign_off — signature + finish step atomic
POST /fp/workspace/advance_milestone — fire next_milestone_action
Each endpoint logs INFO on success, EXCEPTION on failure, returns a
consistent {'ok': bool, 'error': str?} envelope. Hold endpoint isolates
photo-attach failures so they don't roll back the hold record.
Tests cover: payload shape, bad job_id, hold create with/without photo,
empty qty rejection, empty-signature rejection, sign-off finish, and
the no-milestone-action error path.
Verify on entech: -u fusion_plating_shopfloor --test-tags fp_shopfloor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,4 @@ from . import shopfloor_controller
|
||||
from . import manager_controller
|
||||
from . import tank_status
|
||||
from . import move_controller
|
||||
from . import workspace_controller
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# -*- 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).
|
||||
|
||||
Endpoints:
|
||||
POST /fp/workspace/load — full payload for one fp.job
|
||||
POST /fp/workspace/hold — create quality.hold with photo
|
||||
POST /fp/workspace/sign_off — capture signature + finish step
|
||||
POST /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
|
||||
Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md
|
||||
"""
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class FpWorkspaceController(http.Controller):
|
||||
"""JSON-RPC endpoints for the JobWorkspace OWL client action."""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/load — full workspace payload
|
||||
# ======================================================================
|
||||
@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, n=step.recipe_node_id: o.node_id.id == n.id
|
||||
) if 'override_ids' in job._fields else env['fp.job.node.override']
|
||||
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': bool(step.can_start) if 'can_start' in step._fields else (
|
||||
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', step.browse())
|
||||
),
|
||||
})
|
||||
|
||||
# ---- 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 ----------------------------------------
|
||||
try:
|
||||
needs = list(job._resolve_required_cert_types())
|
||||
except Exception:
|
||||
needs = []
|
||||
try:
|
||||
has_draft = bool(job._fp_has_draft_required_certs())
|
||||
except Exception:
|
||||
has_draft = False
|
||||
required_certs = {'needs': needs, 'has_draft': has_draft}
|
||||
|
||||
# ---- Active step (the one in_progress) --------------------------
|
||||
active = (
|
||||
job.active_step_id
|
||||
if 'active_step_id' in job._fields and job.active_step_id
|
||||
else 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 'part_catalog_id' in job._fields and 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,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/hold — create a quality.hold from HoldComposer
|
||||
# ======================================================================
|
||||
@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'}
|
||||
if not qty_on_hold or int(qty_on_hold) < 1:
|
||||
return {'ok': False, 'error': 'qty_on_hold must be at least 1'}
|
||||
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
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 Hold._fields:
|
||||
hold_vals['x_fc_job_id'] = job.id
|
||||
if step_id and 'x_fc_step_id' in Hold._fields:
|
||||
hold_vals['x_fc_step_id'] = int(step_id)
|
||||
|
||||
try:
|
||||
hold = Hold.create(hold_vals)
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/hold: create failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# Attach photo if provided (base64 string from the camera input).
|
||||
# Photo attach failure does NOT roll back the hold — log + continue.
|
||||
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(
|
||||
"workspace/hold: photo attach failed for hold %s", hold.id,
|
||||
)
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@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'}
|
||||
|
||||
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it)
|
||||
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(
|
||||
"workspace/sign_off: attachment failed for step %s", step.id,
|
||||
)
|
||||
return {'ok': False, 'error': 'Failed to save signature.'}
|
||||
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/sign_off: button_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,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/advance_milestone — fire next_milestone_action
|
||||
# ======================================================================
|
||||
@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 by uid %s", job.name, env.uid,
|
||||
)
|
||||
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 '',
|
||||
}
|
||||
Reference in New Issue
Block a user