From a521b7c37beee3f6c13f2fc9e920b7cd87251f67 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 21:10:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(sub12b):=20consolidated=20tablet=20control?= =?UTF-8?q?ler=20=E2=80=94=20Move/Rack/Timer=20(Tasks=208-10+17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../controllers/__init__.py | 1 + .../controllers/move_controller.py | 486 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py index 69b5026a..3daf2496 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py @@ -5,3 +5,4 @@ from . import shopfloor_controller from . import manager_controller from . import tank_status +from . import move_controller diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py new file mode 100644 index 00000000..2a521485 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py @@ -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