feat(sub12b): consolidated tablet controller — Move/Rack/Timer (Tasks 8-10+17)

11 routes (consolidated from plan Tasks 8/9/10/17):
  Move Parts:
    /fp/tablet/move_parts/preview
    /fp/tablet/move_parts/commit
  Move Rack:
    /fp/tablet/move_rack/preview
    /fp/tablet/move_rack/commit
  Rack Parts:
    /fp/tablet/rack_parts/commit
    /fp/tablet/rack/list_empty
    /fp/tablet/rack/scan_qr
  Persistent labor timer:
    /fp/tablet/labor_timer/start
    /fp/tablet/labor_timer/pause
    /fp/tablet/labor_timer/resume
    /fp/tablet/labor_timer/stop
    /fp/tablet/labor_timer/reconcile

Manager-bypass context flags (Task 17 wired in here for cohesion):
  fp_skip_predecessor_check  → bypasses S14 lock
  fp_skip_rack_assignment    → bypasses requires_rack_assignment
  fp_skip_transition_form    → bypasses required transition prompts

All bypass uses post to chatter on the move record naming the user
+ which flags fired. Group check enforced (manager-only).

_safe() wrapper: UserError → JSONRPC-friendly {ok: False, error: msg}
so the OWL components can show a flash without crashing.

Field naming follows existing fp.job.step.timelog convention
(date_started / date_finished, NOT started_at / stopped_at).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-27 21:10:28 -04:00
parent 3bed76aea4
commit a521b7c37b
2 changed files with 487 additions and 0 deletions

View File

@@ -5,3 +5,4 @@
from . import shopfloor_controller
from . import manager_controller
from . import tank_status
from . import move_controller

View File

@@ -0,0 +1,486 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Tablet endpoints for Sub 12b — Move Parts / Move Rack / Rack Parts /
Stop Timer dialogs.
All routes JSONRPC, auth='user'. Errors return {ok: False, error: msg}
so the tablet UI can show a flash without crashing the OWL component.
"""
from odoo import _, fields, http
from odoo.exceptions import UserError
from odoo.http import request
class FpTabletMoveController(http.Controller):
"""Move Parts / Move Rack / Rack Parts / Stop Timer endpoints."""
# ============================================================ helpers
def _safe(self, method, *args, **kwargs):
"""Wrap UserError → JSONRPC-friendly {ok: False, error: msg}.
Any other exception bubbles up as a 500. We catch UserError
because that's what our action methods raise on guard
failures.
"""
try:
result = method(*args, **kwargs)
return {'ok': True, **(result or {})}
except UserError as exc:
return {'ok': False, 'error': str(exc.args[0])}
def _step_payload(self, step):
return {
'id': step.id,
'name': step.name,
'tank_id': step.tank_id.id if step.tank_id else False,
'tank_name': step.tank_id.name if step.tank_id else '',
'tank_options': self._tank_options_for(step),
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
}
def _tank_options_for(self, step):
"""Returns the list of allowed tanks for this step.
Pulls from the recipe node's tank_ids M2M (Sub 12a authored
list). Falls back to [] if the recipe node has none — runtime
will hide the To Station selector.
"""
node = step.recipe_node_id
if not node or 'tank_ids' not in node._fields:
return []
return [
{'id': t.id, 'name': t.name, 'code': t.code}
for t in node.tank_ids
]
def _transition_prompts_for(self, step):
"""Returns the snapshot transition_input rows on a step's
recipe node. Sub 12a's snapshot copy puts these on
process.node.input rows with kind='transition_input'.
"""
node = step.recipe_node_id
if not node or 'input_ids' not in node._fields:
return []
prompts = []
for inp in node.input_ids.filtered(
lambda i: i.kind == 'transition_input'
):
prompts.append({
'id': inp.id,
'name': inp.name,
'input_type': inp.input_type,
'required': inp.required,
'hint': inp.hint or '',
'selection_options': inp.selection_options or '',
'compliance_tag': inp.compliance_tag or 'none',
})
return prompts
def _blockers_for_move(self, from_step, to_step, qty):
"""Compute the blockers list for a move.
Each blocker has type/severity/message/resolve_action keys.
The tablet renders them with inline buttons. severity='soft'
MOVE stays enabled; severity='hard' → MOVE disabled until
resolved.
Manager-bypass context flags fp_skip_predecessor_check /
fp_skip_rack_assignment / fp_skip_transition_form drop the
corresponding blockers when the user has the manager group
(Task 17 wires them).
"""
blockers = []
ctx = request.env.context
is_manager = request.env.user.has_group(
'fusion_plating.group_fusion_plating_manager')
skip_predecessor = bool(
ctx.get('fp_skip_predecessor_check') and is_manager)
skip_rack = bool(
ctx.get('fp_skip_rack_assignment') and is_manager)
# 1. Rack-required gate (soft block — operator may RACK PARTS)
if (to_step.requires_rack_assignment
and not from_step.rack_id
and not skip_rack):
blockers.append({
'type': 'rack_required',
'severity': 'soft',
'message': _("Parts must be racked before moving to %s.") % to_step.name,
'resolve_action': 'open_rack_parts_dialog',
})
# 2. Predecessor lock (S14 hard block)
if to_step.requires_predecessor_done and not skip_predecessor:
unfinished = to_step.job_id.step_ids.filtered(
lambda s: s.sequence < to_step.sequence
and s.state not in ('done', 'skipped', 'cancelled')
)
if unfinished:
blockers.append({
'type': 'predecessor_lock',
'severity': 'hard',
'message': _("Predecessor not done: %s") % unfinished[0].name,
'resolve_action': 'open_predecessor_step',
'resolve_step_id': unfinished[0].id,
})
return blockers
# ===================================================== Move Parts
@http.route('/fp/tablet/move_parts/preview',
type='jsonrpc', auth='user')
def move_parts_preview(self, from_step_id, to_step_id):
Step = request.env['fp.job.step']
from_step = Step.browse(from_step_id)
to_step = Step.browse(to_step_id)
qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
return {
'ok': True,
'qty_available': qty,
'from_step': self._step_payload(from_step),
'to_step': self._step_payload(to_step),
'transition_prompts': self._transition_prompts_for(to_step),
'blockers': self._blockers_for_move(from_step, to_step, qty),
}
@http.route('/fp/tablet/move_parts/commit',
type='jsonrpc', auth='user')
def move_parts_commit(self, from_step_id, to_step_id, qty,
transfer_type='step', to_tank_id=False,
to_location='global', photo_attachment_id=False,
customer_wo_count=0, prompt_values=None):
return self._safe(
self._do_move_parts_commit,
from_step_id, to_step_id, qty, transfer_type,
to_tank_id, to_location, photo_attachment_id,
customer_wo_count, prompt_values or {},
)
def _do_move_parts_commit(self, from_step_id, to_step_id, qty,
transfer_type, to_tank_id, to_location,
photo_attachment_id, customer_wo_count,
prompt_values):
Step = request.env['fp.job.step']
Move = request.env['fp.job.step.move']
from_step = Step.browse(from_step_id)
to_step = Step.browse(to_step_id)
# Hard-block re-check on commit (defence in depth — preview can
# lie if state changed between preview and commit).
blockers = self._blockers_for_move(from_step, to_step, qty)
hard = [b for b in blockers if b['severity'] == 'hard']
if hard:
raise UserError(hard[0]['message'])
qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
move = Move.create({
'job_id': from_step.job_id.id,
'from_step_id': from_step.id,
'to_step_id': to_step.id,
'transfer_type': transfer_type,
'qty_moved': qty,
'qty_available_at_move': qty_avail,
'to_tank_id': to_tank_id or False,
'to_location': to_location,
'photo_evidence_id': photo_attachment_id or False,
'customer_wo_count': customer_wo_count or 0,
})
for prompt_id, value in (prompt_values or {}).items():
self._capture_prompt_value(move, int(prompt_id), value)
# Advance qty_at_step counters
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
# Manager-bypass audit trail
ctx = request.env.context
bypass_flags = [
f for f in ('fp_skip_predecessor_check', 'fp_skip_rack_assignment',
'fp_skip_transition_form')
if ctx.get(f)
]
if bypass_flags and request.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'):
move.message_post(body=_(
"Manager bypass activated by %s — flags: %s"
) % (request.env.user.name, ', '.join(bypass_flags)))
return {'move_id': move.id, 'move_name': move.name}
def _capture_prompt_value(self, move, node_input_id, value):
Value = request.env['fp.job.step.move.input.value']
node_input = request.env[
'fusion.plating.process.node.input'].browse(node_input_id)
payload = {
'move_id': move.id,
'node_input_id': node_input.id,
}
t = node_input.input_type
if t in ('text', 'selection', 'time_hms', 'location_picker'):
payload['value_text'] = value or ''
elif t in ('number', 'temperature', 'thickness', 'time_seconds',
'customer_wo'):
try:
payload['value_number'] = float(value) if value not in (None, '') else 0.0
except (TypeError, ValueError):
payload['value_text'] = str(value or '')
elif t in ('boolean', 'pass_fail'):
payload['value_boolean'] = bool(value)
elif t == 'date':
payload['value_date'] = value or False
elif t in ('signature', 'photo'):
payload['value_attachment_id'] = int(value) if value else False
else:
payload['value_text'] = str(value) if value is not None else ''
Value.create(payload)
# ===================================================== Move Rack
@http.route('/fp/tablet/move_rack/preview',
type='jsonrpc', auth='user')
def move_rack_preview(self, rack_id, to_step_id):
Rack = request.env['fusion.plating.rack']
Step = request.env['fp.job.step']
rack = Rack.browse(rack_id)
to_step = Step.browse(to_step_id)
batches = Step.search([('rack_id', '=', rack.id)])
return {
'ok': True,
'rack': {
'id': rack.id,
'name': rack.name,
'tag_ids': [
{'id': t.id, 'name': t.name, 'color': t.color}
for t in rack.tag_ids
],
},
'batches': [
{
'step_id': s.id,
'qty': (s.qty_done or 0) - (s.qty_scrapped or 0),
'part_number': (s.job_id.product_id.default_code or ''),
'wo_number': s.job_id.name or '',
}
for s in batches
],
'to_step': self._step_payload(to_step),
}
@http.route('/fp/tablet/move_rack/commit',
type='jsonrpc', auth='user')
def move_rack_commit(self, rack_id, to_step_id, transfer_type='step',
to_tank_id=False):
return self._safe(
self._do_move_rack_commit,
rack_id, to_step_id, transfer_type, to_tank_id,
)
def _do_move_rack_commit(self, rack_id, to_step_id, transfer_type,
to_tank_id):
Rack = request.env['fusion.plating.rack']
Step = request.env['fp.job.step']
Move = request.env['fp.job.step.move']
rack = Rack.browse(rack_id)
to_step = Step.browse(to_step_id)
moves = []
for batch in Step.search([('rack_id', '=', rack.id)]):
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)
move = Move.create({
'job_id': batch.job_id.id,
'from_step_id': batch.id,
'to_step_id': to_step.id,
'transfer_type': transfer_type,
'qty_moved': qty,
'rack_id': rack.id,
'to_tank_id': to_tank_id or False,
})
batch.qty_at_step_finish = qty
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
moves.append(move.id)
rack.racking_state = 'in_use'
return {'move_ids': moves, 'count': len(moves)}
# =================================================== Rack Parts
@http.route('/fp/tablet/rack_parts/commit',
type='jsonrpc', auth='user')
def rack_parts_commit(self, from_step_id, rack_id, qty=None):
return self._safe(
self._do_rack_parts_commit, from_step_id, rack_id, qty,
)
def _do_rack_parts_commit(self, from_step_id, rack_id, qty):
Rack = request.env['fusion.plating.rack']
Step = request.env['fp.job.step']
rack = Rack.browse(rack_id)
if rack.racking_state not in ('empty', 'loading'):
raise UserError(_(
"Rack %s is not available (racking_state=%s)."
) % (rack.name, rack.racking_state))
from_step = Step.browse(from_step_id)
from_step.rack_id = rack.id
rack.racking_state = 'loaded'
return {'rack_id': rack.id, 'rack_name': rack.name}
@http.route('/fp/tablet/rack/list_empty',
type='jsonrpc', auth='user')
def rack_list_empty(self, query=''):
Rack = request.env['fusion.plating.rack']
domain = [
('racking_state', '=', 'empty'),
('active', '=', True),
]
if query:
domain += ['|', ('name', 'ilike', query),
('rack_type', 'ilike', query)]
records = Rack.search(domain, limit=50)
return {
'ok': True,
'racks': [
{'id': r.id, 'name': r.name, 'rack_type': r.rack_type}
for r in records
],
}
@http.route('/fp/tablet/rack/scan_qr',
type='jsonrpc', auth='user')
def rack_scan_qr(self, qr_code):
prefix = 'FP-RACK:'
if not qr_code.startswith(prefix):
return {'ok': False, 'error': _("Not a rack QR code.")}
name = qr_code[len(prefix):]
rack = request.env['fusion.plating.rack'].search(
[('name', '=', name)], limit=1,
)
if not rack:
return {'ok': False, 'error': _("Rack %s not found.") % name}
return {
'ok': True,
'rack_id': rack.id,
'rack_name': rack.name,
'racking_state': rack.racking_state,
}
# ============================================ Persistent labor timer
@http.route('/fp/tablet/labor_timer/start',
type='jsonrpc', auth='user')
def labor_timer_start(self, step_id):
return self._safe(self._do_labor_timer_start, step_id)
def _do_labor_timer_start(self, step_id):
Tl = request.env['fp.job.step.timelog']
# Auto-pause any other running timer for this user
running = Tl.search([
('user_id', '=', request.env.uid),
('state', '=', 'running'),
])
for r in running:
r.state = 'paused'
r.last_paused_at = fields.Datetime.now()
new = Tl.create({
'step_id': step_id,
'user_id': request.env.uid,
'date_started': fields.Datetime.now(),
'state': 'running',
})
return {'timer_id': new.id, 'date_started': new.date_started.isoformat()}
@http.route('/fp/tablet/labor_timer/pause',
type='jsonrpc', auth='user')
def labor_timer_pause(self, timer_id):
return self._safe(self._do_labor_timer_pause, timer_id)
def _do_labor_timer_pause(self, timer_id):
tl = request.env['fp.job.step.timelog'].browse(timer_id)
if tl.state != 'running':
raise UserError(_("Timer is not running."))
tl.state = 'paused'
tl.last_paused_at = fields.Datetime.now()
return {'state': 'paused', 'accrued_seconds': tl.accrued_seconds}
@http.route('/fp/tablet/labor_timer/resume',
type='jsonrpc', auth='user')
def labor_timer_resume(self, timer_id):
return self._safe(self._do_labor_timer_resume, timer_id)
def _do_labor_timer_resume(self, timer_id):
tl = request.env['fp.job.step.timelog'].browse(timer_id)
if tl.state != 'paused':
raise UserError(_("Timer is not paused."))
if tl.last_paused_at:
paused_dur = (
fields.Datetime.now() - tl.last_paused_at
).total_seconds()
tl.total_paused_seconds = (tl.total_paused_seconds or 0) + int(paused_dur)
tl.state = 'running'
tl.last_paused_at = False
return {'state': 'running', 'accrued_seconds': tl.accrued_seconds}
@http.route('/fp/tablet/labor_timer/stop',
type='jsonrpc', auth='user')
def labor_timer_stop(self, timer_id):
return self._safe(self._do_labor_timer_stop, timer_id)
def _do_labor_timer_stop(self, timer_id):
tl = request.env['fp.job.step.timelog'].browse(timer_id)
if tl.state in ('stopped', 'reconciled'):
raise UserError(_("Timer already stopped."))
if tl.state == 'paused' and tl.last_paused_at:
paused_dur = (
fields.Datetime.now() - tl.last_paused_at
).total_seconds()
tl.total_paused_seconds = (tl.total_paused_seconds or 0) + int(paused_dur)
tl.last_paused_at = False
tl.date_finished = fields.Datetime.now()
tl.state = 'stopped'
# Default billed = accrued (operator can edit before reconcile)
secs = tl.accrued_seconds
tl.billed_hrs = secs // 3600
tl.billed_min = (secs % 3600) // 60
tl.billed_sec = secs % 60
return {
'state': 'stopped',
'accrued_seconds': tl.accrued_seconds,
'billed_hrs': tl.billed_hrs,
'billed_min': tl.billed_min,
'billed_sec': tl.billed_sec,
}
@http.route('/fp/tablet/labor_timer/reconcile',
type='jsonrpc', auth='user')
def labor_timer_reconcile(self, timer_id, billed_hrs=0, billed_min=0,
billed_sec=0, product_id=False, notes='',
start_new=False):
return self._safe(
self._do_labor_timer_reconcile,
timer_id, billed_hrs, billed_min, billed_sec,
product_id, notes, start_new,
)
def _do_labor_timer_reconcile(self, timer_id, billed_hrs, billed_min,
billed_sec, product_id, notes, start_new):
tl = request.env['fp.job.step.timelog'].browse(timer_id)
tl.write({
'billed_hrs': billed_hrs or 0,
'billed_min': billed_min or 0,
'billed_sec': billed_sec or 0,
'product_id': product_id or False,
'notes': notes or '',
'state': 'reconciled',
})
result = {'state': 'reconciled', 'timer_id': tl.id}
if start_new:
new = self._do_labor_timer_start(tl.step_id.id)
result['new_timer_id'] = new['timer_id']
return result