feat(jobs): Phase 6 — Tablet Station for fp.job
Operator-facing touchscreen UI. Three modes: - job_picker: list of active jobs as big touch cards - job_detail: job header + steps list, click a step to view detail - step_detail: big Start/Finish buttons depending on state Backend: 4 JSON-RPC endpoints under /fp/jobs/tablet/* for jobs list, job detail, start step, finish step. Calls through to fp.job.step.button_start / button_finish so all the audit preservation, timelog creation, duration_actual roll-up logic from Phase 1 still applies. Menu entry 'Tablet Station (Native)' at sequence 3 (top) of the Plating Jobs (Native) submenu inside the existing Plating app. Manifest 19.0.2.3.0 → 19.0.2.4.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
188
fusion_plating/fusion_plating_jobs/controllers/tablet.py
Normal file
188
fusion_plating/fusion_plating_jobs/controllers/tablet.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# -*- 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