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:
@@ -5,3 +5,4 @@
|
||||
from . import shopfloor_controller
|
||||
from . import manager_controller
|
||||
from . import tank_status
|
||||
from . import move_controller
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user