diff --git a/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-pin-gate-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-pin-gate-plan.md new file mode 100644 index 00000000..199d5720 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-pin-gate-plan.md @@ -0,0 +1,2557 @@ +# Shop Floor PIN Gate + Auto-Lock Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a tile-grid lock screen + 4-digit PIN per tech + auto-lock after idle so multiple techs sharing one tablet get correctly-attributed audit trails on every shop-floor action. + +**Architecture:** Single Odoo session per tablet, PIN-gated overlay on top via a new `FpTabletLock` OWL wrapper. Backend stores PBKDF2-SHA256 hashed PINs on `res.users`; lockout per-user after 5 failures; idle detection via global pointer/touch/keydown listeners. Audit propagation via optional `tablet_tech_id` kwarg added to existing endpoints — server uses `env.with_user(...)` so writes carry the right uid without JS bundle reload. + +**Tech Stack:** Odoo 19 (Python controllers, models, data XML), OWL 2.x (`static template`, `static props = ["*"]`, standalone `rpc()`, services registry), PBKDF2-SHA256 (Python stdlib), SCSS with `$o-webclient-color-scheme` dark-mode branch. + +**Spec:** [docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md](../specs/2026-05-22-shopfloor-pin-gate-design.md) + +**Local dev note:** The Phase 1-5 deploy established that `fusion_plating` is NOT mounted in the local `odoo-modsdev-app` container. Tests are written per task and committed but verified on the live entech LXC (`ssh pve-worker5 'pct exec 111 -- ...'`). The plan's "verify test fails / passes" steps describe the expected entech behaviour for the executing agent to confirm after deploy. + +--- + +## Module version bumps (apply with each phase's final task) + +| Module | Current | Phase 6.1 | Phase 6.2 | Phase 6.3 | +|---|---|---|---|---| +| `fusion_plating_shopfloor` | 19.0.29.0.0 | 19.0.30.0.0 (backend) | 19.0.30.1.0 (frontend lock) | 19.0.30.2.0 (audit kwarg) | + +--- + +## File structure + +### NEW files + +``` +fusion_plating_shopfloor/ + models/ + res_users.py [P6.1] + controllers/ + tablet_controller.py [P6.1] + views/ + res_users_views.xml [P6.1] + data/ + fp_tablet_config_data.xml [P6.1] + tests/ + test_tablet_pin.py [P6.1] + + static/src/js/services/ + tech_store.js [P6.2] + activity_tracker.js [P6.2] + fp_rpc.js [P6.3] + static/src/js/components/ + pin_pad.js [P6.2] + pin_setup.js [P6.2] + idle_warning.js [P6.2] + static/src/xml/components/ + pin_pad.xml [P6.2] + pin_setup.xml [P6.2] + idle_warning.xml [P6.2] + static/src/scss/components/ + _pin_pad.scss [P6.2] + _idle_warning.scss [P6.2] + + static/src/js/ + tablet_lock.js [P6.2] + static/src/xml/ + tablet_lock.xml [P6.2] + static/src/scss/ + tablet_lock.scss [P6.2] +``` + +### MODIFIED files + +``` +fusion_plating_shopfloor/ + __init__.py [P6.1: + models, controllers/tablet_controller] + __manifest__.py [P6.1, 6.2, 6.3 — version + data + assets] + models/__init__.py [P6.1: + res_users] + controllers/__init__.py [P6.1: + tablet_controller] + models/fp_shopfloor_station.py [P6.1: + authorised_user_ids, idle_lock_minutes] + views/fp_shopfloor_station_views.xml [P6.1: + 2 new fields in form] + controllers/workspace_controller.py [P6.3: accept tablet_tech_id] + controllers/shopfloor_controller.py [P6.3: accept tablet_tech_id on action endpoints] + controllers/manager_controller.py [P6.3: accept tablet_tech_id on action endpoints] + static/src/js/shopfloor_landing.js [P6.2: wrap in FpTabletLock + Hand-Off | P6.3: use fpRpc] + static/src/js/job_workspace.js [P6.2: wrap + Hand-Off | P6.3: use fpRpc] + static/src/js/manager_dashboard.js [P6.2: wrap + Hand-Off | P6.3: use fpRpc] + static/src/xml/shopfloor_landing.xml [P6.2: Hand-Off button slot] + static/src/xml/job_workspace.xml [P6.2: Hand-Off button slot] + static/src/xml/manager_dashboard.xml [P6.2: Hand-Off button slot] +``` + +--- + +# Phase 6.1 — Backend (model + endpoints + prefs) + +**Goal:** Ship invisible PIN infrastructure. Techs can set PINs via Preferences, manager can reset PINs, all five `/fp/tablet/*` endpoints exist and work. No UI lock screen yet — Phase 6.2 turns it on. + +--- + +### Task 6.1.1 — res.users PIN model + hash helpers + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/models/__init__.py` — verify exists; add `from . import res_users` +- Create: `fusion_plating/fusion_plating_shopfloor/models/res_users.py` +- Create: `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py` +- Modify: `fusion_plating/fusion_plating_shopfloor/__init__.py` — verify `from . import models` exists + +- [ ] **Step 1: Write the failing test** + +Create `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. — License OPL-1 +"""Phase 6.1 — res.users PIN fields + hash helpers.""" +from odoo.tests.common import TransactionCase, tagged +from odoo.exceptions import AccessError, UserError + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletPinHash(TransactionCase): + + def setUp(self): + super().setUp() + self.user = self.env['res.users'].create({ + 'name': 'Pin Test', + 'login': 'pintest@example.com', + }) + + def test_set_pin_stores_salted_hash(self): + self.user.sudo().set_tablet_pin('1234') + stored = self.user.sudo().x_fc_tablet_pin_hash + self.assertTrue(stored) + self.assertIn('$', stored, 'hash must include salt separator') + # Hash is non-deterministic — setting same PIN twice gives different stored values + self.user.sudo().set_tablet_pin('1234') + self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash) + + def test_verify_correct_pin(self): + self.user.sudo().set_tablet_pin('1234') + self.assertTrue(self.user.sudo().verify_tablet_pin('1234')) + + def test_verify_wrong_pin(self): + self.user.sudo().set_tablet_pin('1234') + self.assertFalse(self.user.sudo().verify_tablet_pin('0000')) + + def test_verify_pin_no_hash_set(self): + self.assertFalse(self.user.sudo().verify_tablet_pin('1234')) +``` + +- [ ] **Step 2: Verify test fails on entech** + +After deploy: `odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --test-tags fp_tablet_pin --stop-after-init` +Expected: FAIL with `AttributeError: 'res.users' object has no attribute 'set_tablet_pin'`. + +- [ ] **Step 3: Implement res.users model** + +Create `fusion_plating/fusion_plating_shopfloor/models/res_users.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign). + +Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints +and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256 +hash; never plaintext. +""" +import hashlib +import secrets + +from odoo import _, fields, models +from odoo.exceptions import UserError + +# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe +# against brute-force even if the DB leaks. +_PBKDF2_ITERATIONS = 200_000 + + +class ResUsers(models.Model): + _inherit = 'res.users' + + x_fc_tablet_pin_hash = fields.Char( + string='Tablet PIN (hashed)', + groups='fusion_plating.group_fusion_plating_manager', + help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet ' + 'PIN. Format: $. Never readable to ' + 'non-managers; never logged.', + ) + x_fc_tablet_pin_set_date = fields.Datetime( + string='Tablet PIN Set Date', + help='When the current PIN was last set or changed.', + ) + x_fc_tablet_pin_failed_count = fields.Integer( + string='Failed PIN Attempts', + default=0, + help='Sequential failed unlock attempts since the last success. ' + 'Resets to 0 on a correct PIN.', + ) + x_fc_tablet_locked_until = fields.Datetime( + string='Tablet Lockout Until', + help='Wall-clock time at which the per-user lockout expires. ' + 'Null when not locked. Set after the configured fail ' + 'threshold (default 5) is reached.', + ) + + @staticmethod + def _hash_tablet_pin(pin, salt=None): + """Hash `pin` with optional salt. Returns "salt_hex$digest_hex".""" + if salt is None: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac( + 'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS, + ) + return f"{salt.hex()}${digest.hex()}" + + @staticmethod + def _verify_tablet_pin_hash(pin, stored): + """Constant-time verify of `pin` against a stored hash string.""" + if not stored or '$' not in stored: + return False + salt_hex, expected_hex = stored.split('$', 1) + try: + salt = bytes.fromhex(salt_hex) + except ValueError: + return False + digest = hashlib.pbkdf2_hmac( + 'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS, + ) + return secrets.compare_digest(digest.hex(), expected_hex) + + def set_tablet_pin(self, pin): + """Set or change this user's tablet PIN. Requires sudo OR self. + + Caller is responsible for verifying the OLD pin separately if a + hash already exists — this method just writes the new one. + """ + self.ensure_one() + if not pin or not pin.isdigit() or len(pin) != 4: + raise UserError(_('Tablet PIN must be exactly 4 digits.')) + self.sudo().write({ + 'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin), + 'x_fc_tablet_pin_set_date': fields.Datetime.now(), + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + return True + + def verify_tablet_pin(self, pin): + """Return True if `pin` matches this user's stored hash.""" + self.ensure_one() + if not pin: + return False + # sudo: even non-manager callers may need to verify their OWN PIN. + # The hash field has manager-only read; sudo bypasses that. + return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash) + + def clear_tablet_pin(self): + """Manager-side reset. Clears hash so target must set a new PIN. + Posts to chatter for audit. + """ + self.ensure_one() + manager_name = self.env.user.name + self.sudo().write({ + 'x_fc_tablet_pin_hash': False, + 'x_fc_tablet_pin_set_date': False, + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + self.message_post( + body=_('Tablet PIN reset by %s. User must set a new PIN ' + 'on next unlock attempt.') % manager_name, + ) + return True +``` + +Verify `fusion_plating/fusion_plating_shopfloor/models/__init__.py` includes `from . import res_users`. If file doesn't exist yet, create it: + +```python +# -*- coding: utf-8 -*- +from . import fp_bake_oven +from . import fp_bake_window +from . import fp_first_piece_gate +from . import fp_operator_queue +from . import fp_shopfloor_station +from . import fp_tank +from . import res_users +``` + +(The first 6 imports likely already exist — only `res_users` is new.) + +Verify `fusion_plating/fusion_plating_shopfloor/__init__.py` includes `from . import models`. If not, add it. + +Create `fusion_plating/fusion_plating_shopfloor/tests/__init__.py` line for the new test file. Append: + +```python +from . import test_tablet_pin +``` + +- [ ] **Step 4: Verify test passes on entech** + +After deploy: `odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --test-tags fp_tablet_pin --stop-after-init` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /k/Github/Odoo-Modules +git add fusion_plating/fusion_plating_shopfloor/models/__init__.py \ + fusion_plating/fusion_plating_shopfloor/models/res_users.py \ + fusion_plating/fusion_plating_shopfloor/__init__.py \ + fusion_plating/fusion_plating_shopfloor/tests/__init__.py \ + fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py +git commit -m "feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)" +``` + +--- + +### Task 6.1.2 — /fp/tablet/set_pin + /fp/tablet/reset_pin_for endpoints + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py` +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/__init__.py` — add import +- Modify: `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py` — add HTTP tests + +- [ ] **Step 1: Write the failing tests** + +Append to `test_tablet_pin.py`: + +```python +import json +from odoo.tests.common import HttpCase + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletSetPin(HttpCase): + + def setUp(self): + super().setUp() + # admin auth so the test can call /fp/tablet/set_pin against + # the currently-authenticated user. + self.authenticate("admin", "admin") + + def _rpc(self, url, **params): + res = self.url_open( + url, + data=json.dumps({'jsonrpc': '2.0', 'params': params}), + headers={'Content-Type': 'application/json'}, + ) + return res.json()['result'] + + def test_set_pin_first_time(self): + res = self._rpc('/fp/tablet/set_pin', new_pin='1234') + self.assertTrue(res['ok']) + + def test_set_pin_change_requires_old(self): + # Seed a pin + admin = self.env.ref('base.user_admin') + admin.set_tablet_pin('1234') + # Change without old — rejected + res = self._rpc('/fp/tablet/set_pin', new_pin='5678') + self.assertFalse(res['ok']) + # Change with wrong old — rejected + res = self._rpc('/fp/tablet/set_pin', old_pin='9999', new_pin='5678') + self.assertFalse(res['ok']) + # Change with correct old — accepted + res = self._rpc('/fp/tablet/set_pin', old_pin='1234', new_pin='5678') + self.assertTrue(res['ok']) + + def test_set_pin_rejects_non_4_digit(self): + for bad in ('123', '12345', 'abcd', ''): + res = self._rpc('/fp/tablet/set_pin', new_pin=bad) + self.assertFalse(res['ok'], f'PIN {bad!r} should be rejected') + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletResetPinFor(HttpCase): + + def setUp(self): + super().setUp() + self.target = self.env['res.users'].create({ + 'name': 'Reset Target', 'login': 'reset@example.com', + }) + self.target.set_tablet_pin('1234') + + def _rpc_as(self, login, url, **params): + self.authenticate(login, login) + res = self.url_open( + url, + data=json.dumps({'jsonrpc': '2.0', 'params': params}), + headers={'Content-Type': 'application/json'}, + ) + return res.json()['result'] + + def test_reset_requires_manager_group(self): + # Plain operator can't reset + operator = self.env['res.users'].create({ + 'name': 'Op', 'login': 'op@example.com', + 'group_ids': [(6, 0, [self.env.ref('fusion_plating.group_fusion_plating_operator').id])], + }) + self.authenticate('op@example.com', 'op@example.com') + res = self.url_open( + '/fp/tablet/reset_pin_for', + data=json.dumps({'jsonrpc': '2.0', 'params': {'user_id': self.target.id}}), + headers={'Content-Type': 'application/json'}, + ).json()['result'] + self.assertFalse(res['ok']) + + def test_reset_as_admin_clears_hash(self): + self.authenticate('admin', 'admin') + res = self.url_open( + '/fp/tablet/reset_pin_for', + data=json.dumps({'jsonrpc': '2.0', 'params': {'user_id': self.target.id}}), + headers={'Content-Type': 'application/json'}, + ).json()['result'] + self.assertTrue(res['ok']) + self.target.invalidate_recordset(['x_fc_tablet_pin_hash']) + self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash) +``` + +- [ ] **Step 2: Verify tests fail on entech** + +Run the test. Expected: 404 errors (endpoint missing). + +- [ ] **Step 3: Implement controller** + +Create `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py`: + +```python +# -*- 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 tablet PIN gate (Phase 6 tablet redesign). + + POST /fp/tablet/tiles — list of tiles for the lock screen + POST /fp/tablet/unlock — verify PIN + clear/increment failure counter + POST /fp/tablet/set_pin — self-service set/change PIN + POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN + POST /fp/tablet/ping — bump server-side last-active timestamp + +Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md +""" +import logging + +from odoo import _, fields, http +from odoo.exceptions import AccessError, UserError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _is_manager(env): + """True if calling user is in the fusion_plating manager group.""" + return env.user.has_group('fusion_plating.group_fusion_plating_manager') + + +class FpTabletController(http.Controller): + """Tablet PIN gate endpoints. All require an authenticated Odoo + session (the tablet logs in once as a 'shopfloor service' user). + """ + + # ====================================================================== + # /fp/tablet/set_pin — self-service set or change + # ====================================================================== + @http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user') + def set_pin(self, new_pin, old_pin=None): + env = request.env + user = env.user + existing_hash = user.sudo().x_fc_tablet_pin_hash + if existing_hash: + if not old_pin: + return {'ok': False, 'error': _('Current PIN is required to change it.')} + if not user.verify_tablet_pin(old_pin): + return {'ok': False, 'error': _('Current PIN is incorrect.')} + try: + user.set_tablet_pin(new_pin) + except UserError as e: + return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)} + _logger.info( + "Tablet PIN set/changed for uid %s by self", user.id, + ) + return {'ok': True} + + # ====================================================================== + # /fp/tablet/reset_pin_for — manager-only + # ====================================================================== + @http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user') + def reset_pin_for(self, user_id): + env = request.env + if not _is_manager(env): + _logger.warning( + "Non-manager uid %s attempted to reset PIN for user %s", + env.uid, user_id, + ) + return {'ok': False, 'error': _('Manager privilege required.')} + target = env['res.users'].browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': _('User not found.')} + target.clear_tablet_pin() + _logger.info( + "Tablet PIN reset for uid %s by manager uid %s", + target.id, env.uid, + ) + return {'ok': True} +``` + +Add to `fusion_plating/fusion_plating_shopfloor/controllers/__init__.py`: + +```python +from . import tablet_controller +``` + +- [ ] **Step 4: Verify tests pass on entech** + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py \ + fusion_plating/fusion_plating_shopfloor/controllers/__init__.py \ + fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py +git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/set_pin + reset_pin_for endpoints (P6.1.2)" +``` + +--- + +### Task 6.1.3 — /fp/tablet/unlock endpoint with lockout + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py` +- Modify: `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py` + +- [ ] **Step 1: Write failing tests** + +Append to `test_tablet_pin.py`: + +```python +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletUnlock(HttpCase): + + def setUp(self): + super().setUp() + self.authenticate("admin", "admin") + self.target = self.env['res.users'].create({ + 'name': 'Unlock Target', 'login': 'unlock@example.com', + }) + self.target.set_tablet_pin('1234') + + def _unlock(self, pin): + return self.url_open( + '/fp/tablet/unlock', + data=json.dumps({'jsonrpc': '2.0', 'params': { + 'user_id': self.target.id, 'pin': pin, + }}), + headers={'Content-Type': 'application/json'}, + ).json()['result'] + + def test_unlock_correct_pin(self): + res = self._unlock('1234') + self.assertTrue(res['ok']) + self.assertEqual(res['current_tech_id'], self.target.id) + self.assertEqual(res['current_tech_name'], 'Unlock Target') + + def test_unlock_correct_pin_resets_fail_counter(self): + self._unlock('0000') # fail once + self._unlock('1234') # succeed + self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count']) + self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0) + + def test_unlock_wrong_pin_increments_counter(self): + self._unlock('0000') + self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count']) + self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1) + + def test_lockout_after_5_fails(self): + for _ in range(5): + self._unlock('0000') + res = self._unlock('0000') # 6th + self.assertFalse(res['ok']) + self.assertIn('locked', res['error'].lower()) + self.target.invalidate_recordset(['x_fc_tablet_locked_until']) + self.assertTrue(self.target.sudo().x_fc_tablet_locked_until) + + def test_lockout_blocks_even_correct_pin(self): + for _ in range(5): + self._unlock('0000') + # Even the correct PIN now rejected + res = self._unlock('1234') + self.assertFalse(res['ok']) + self.assertIn('locked', res['error'].lower()) + + def test_unlock_no_pin_set(self): + self.target.clear_tablet_pin() + res = self._unlock('1234') + self.assertFalse(res['ok']) + self.assertIn('not set', res['error'].lower()) +``` + +- [ ] **Step 2: Verify tests fail** + +- [ ] **Step 3: Implement endpoint** + +Append to `tablet_controller.py`: + +```python + # ====================================================================== + # /fp/tablet/unlock — verify PIN + manage failure counter / lockout + # ====================================================================== + @http.route('/fp/tablet/unlock', type='jsonrpc', auth='user') + def unlock(self, user_id, pin): + env = request.env + Users = env['res.users'].sudo() # need sudo to read hash field + target = Users.browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': _('User not found.')} + + # No PIN set yet — caller must set one first + if not target.x_fc_tablet_pin_hash: + return { + 'ok': False, + 'error': _('No PIN set. Set one in Preferences first.'), + 'needs_setup': True, + } + + # Currently locked out? + now = fields.Datetime.now() + if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now: + return { + 'ok': False, + 'error': _('Account locked. Try again in a few minutes.'), + 'locked_until': target.x_fc_tablet_locked_until.isoformat(), + } + + if target.verify_tablet_pin(pin): + # Reset failure state on success + target.write({ + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + _logger.info( + "Tablet unlocked by uid %s (session uid %s)", + target.id, env.uid, + ) + return { + 'ok': True, + 'current_tech_id': target.id, + 'current_tech_name': target.name, + } + + # Wrong PIN — increment and check threshold + new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1 + threshold = int(env['ir.config_parameter'].sudo().get_param( + 'fp.shopfloor.tablet_pin_fail_threshold', 5, + )) + lockout_min = int(env['ir.config_parameter'].sudo().get_param( + 'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5, + )) + vals = {'x_fc_tablet_pin_failed_count': new_count} + if new_count >= threshold: + from datetime import timedelta + vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min) + target.write(vals) + _logger.warning( + "Tablet PIN failure for uid %s (count=%d, locked=%s)", + target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')), + ) + if vals.get('x_fc_tablet_locked_until'): + return { + 'ok': False, + 'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min, + 'locked_until': vals['x_fc_tablet_locked_until'].isoformat(), + } + return { + 'ok': False, + 'error': _('Incorrect PIN.'), + 'attempts_remaining': threshold - new_count, + } +``` + +- [ ] **Step 4: Verify tests pass** + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py \ + fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py +git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/unlock endpoint with lockout (P6.1.3)" +``` + +--- + +### Task 6.1.4 — /fp/tablet/tiles endpoint + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py` +- Modify: `fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py` + +- [ ] **Step 1: Write failing test** + +Append to `test_tablet_pin.py`: + +```python +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletTiles(HttpCase): + + def setUp(self): + super().setUp() + self.authenticate("admin", "admin") + self.op_group = self.env.ref('fusion_plating.group_fusion_plating_operator') + self.alice = self.env['res.users'].create({ + 'name': 'Alice', 'login': 'alice@example.com', + 'group_ids': [(6, 0, [self.op_group.id])], + }) + self.bob = self.env['res.users'].create({ + 'name': 'Bob', 'login': 'bob@example.com', + 'group_ids': [(6, 0, [self.op_group.id])], + }) + self.alice.set_tablet_pin('1111') + + def _tiles(self, station_id=None): + return self.url_open( + '/fp/tablet/tiles', + data=json.dumps({'jsonrpc': '2.0', 'params': { + 'station_id': station_id, + }}), + headers={'Content-Type': 'application/json'}, + ).json()['result'] + + def test_tiles_returns_all_operators_without_station(self): + res = self._tiles() + self.assertTrue(res['ok']) + names = [t['name'] for t in res['tiles']] + self.assertIn('Alice', names) + self.assertIn('Bob', names) + + def test_tile_has_pin_flag(self): + res = self._tiles() + alice_tile = next(t for t in res['tiles'] if t['name'] == 'Alice') + bob_tile = next(t for t in res['tiles'] if t['name'] == 'Bob') + self.assertTrue(alice_tile['has_pin']) + self.assertFalse(bob_tile['has_pin']) +``` + +- [ ] **Step 2: Verify test fails** + +- [ ] **Step 3: Implement endpoint** + +Append to `tablet_controller.py`: + +```python + # ====================================================================== + # /fp/tablet/tiles — lock-screen tile grid + # ====================================================================== + @http.route('/fp/tablet/tiles', type='jsonrpc', auth='user') + def tiles(self, station_id=None): + env = request.env + op_group = env.ref( + 'fusion_plating.group_fusion_plating_operator', + raise_if_not_found=False, + ) + if not op_group: + return {'ok': False, 'error': 'operator group missing'} + + # Determine candidate users — station roster wins if non-empty + users = op_group.user_ids + if station_id: + Station = env['fusion.plating.shopfloor.station'] + station = Station.browse(int(station_id)) + if (station.exists() + and 'x_fc_authorised_user_ids' in station._fields + and station.x_fc_authorised_user_ids): + users = station.x_fc_authorised_user_ids + + # has_pin needs sudo-read on the hash field + users_sudo = users.sudo() + clocked_ids = set() + if 'hr.employee' in env and hasattr( + env['hr.employee'], '_fp_clocked_in_user_ids', + ): + clocked_ids = env['hr.employee']._fp_clocked_in_user_ids() or set() + + tiles = [] + for u, u_sudo in zip(users.sorted('name'), users_sudo.sorted('name')): + tiles.append({ + 'user_id': u.id, + 'name': u.name, + 'avatar_url': f'/web/image/res.users/{u.id}/avatar_128', + 'is_clocked_in': u.id in clocked_ids, + 'has_pin': bool(u_sudo.x_fc_tablet_pin_hash), + }) + # Clocked in first, then alphabetical within bucket + tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name'])) + return {'ok': True, 'tiles': tiles} +``` + +- [ ] **Step 4: Verify test passes** + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py \ + fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py +git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/tiles endpoint (P6.1.4)" +``` + +--- + +### Task 6.1.5 — /fp/tablet/ping endpoint + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py` + +- [ ] **Step 1: Implement** (no test — pure no-op acknowledgement used for forensic logging) + +Append to `tablet_controller.py`: + +```python + # ====================================================================== + # /fp/tablet/ping — heartbeat used by the OWL component on every action + # ====================================================================== + @http.route('/fp/tablet/ping', type='jsonrpc', auth='user') + def ping(self, current_tech_id=None): + """Lightweight heartbeat. Used by the OWL component to confirm + the server-side session is alive AND to log the tech-of-record + every few minutes so the server has forensic visibility into + which tech was 'driving' the tablet at any moment. + + Returns {'ok': True} as a cheap liveness ack. + """ + if current_tech_id: + _logger.debug( + "Tablet ping: session uid %s carrying tablet_tech_id=%s", + request.env.uid, current_tech_id, + ) + return {'ok': True, 'server_time': fields.Datetime.now().isoformat()} +``` + +- [ ] **Step 2: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py +git commit -m "feat(fusion_plating_shopfloor): /fp/tablet/ping heartbeat endpoint (P6.1.5)" +``` + +--- + +### Task 6.1.6 — Station extras + config-parameter defaults + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/models/fp_shopfloor_station.py` +- Create: `fusion_plating/fusion_plating_shopfloor/data/fp_tablet_config_data.xml` +- Modify: `fusion_plating/fusion_plating_shopfloor/views/fp_shopfloor_station_views.xml` +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Add the two station fields** + +In `models/fp_shopfloor_station.py`, locate the field block and add after the last existing field: + +```python + # Phase 6 tablet PIN gate — per-station roster + idle override. + x_fc_authorised_user_ids = fields.Many2many( + 'res.users', + relation='fp_shopfloor_station_authorised_user_rel', + column1='station_id', + column2='user_id', + string='Authorised Operators', + help='If set, the tablet lock screen only shows tiles for these ' + 'users. Empty = all operator-group users are shown. Use to ' + 'restrict a tablet at a specialised station (e.g. EN Plating) ' + 'to techs trained on that station.', + ) + x_fc_idle_lock_minutes = fields.Integer( + string='Idle Lock (minutes)', + help='Per-station override for the auto-lock idle threshold. ' + 'Leave blank to use the global default ' + '(ir.config_parameter fp.shopfloor.tablet_idle_lock_minutes, ' + 'default 5).', + ) +``` + +- [ ] **Step 2: Create the config-parameter data file** + +Create `fusion_plating/fusion_plating_shopfloor/data/fp_tablet_config_data.xml`: + +```xml + + + + + + fp.shopfloor.tablet_idle_lock_minutes + 5 + + + + fp.shopfloor.tablet_pin_fail_threshold + 5 + + + + fp.shopfloor.tablet_pin_fail_lockout_minutes + 5 + + + + fp.shopfloor.tablet_warn_seconds_before_lock + 30 + + + +``` + +- [ ] **Step 3: Surface the new fields on the station form** + +In `views/fp_shopfloor_station_views.xml`, locate the form's field group (somewhere with `name="code"`, `facility_id`, etc.) and add inside: + +```xml + + + + +``` + +If the form doesn't have explicit `` containers, place the two `` lines inside the existing `` near the other config fields. + +- [ ] **Step 4: Register the data file in the manifest** + +Edit `fusion_plating/fusion_plating_shopfloor/__manifest__.py` — find the `'data':` list and add the new file BEFORE `'views/fp_menu.xml'`: + +```python + 'data/fp_tablet_config_data.xml', +``` + +(Order matters — data files load top-to-bottom; this needs to land before any menu/view that might reference the parameters.) + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/models/fp_shopfloor_station.py \ + fusion_plating/fusion_plating_shopfloor/data/fp_tablet_config_data.xml \ + fusion_plating/fusion_plating_shopfloor/views/fp_shopfloor_station_views.xml \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): station roster + idle override + config defaults (P6.1.6)" +``` + +--- + +### Task 6.1.7 — Profile prefs Set/Change PIN button + Manager Reset button + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/views/res_users_views.xml` +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Create the view file** + +```xml + + + + + + + res.users.preferences.tablet.pin + res.users + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Create SCSS** + +```scss +// ============================================================================= +// FpPinPad — numeric keypad for tablet lock screen + PIN setup +// Dark-mode aware via $o-webclient-color-scheme branch. +// ============================================================================= + +$o-webclient-color-scheme: bright !default; + +$_pin-bg-hex: #ffffff; +$_pin-key-bg-hex: #f3f4f6; +$_pin-key-hover-hex: #e5e7eb; +$_pin-border-hex: #d8dadd; +$_pin-dot-hex: #d8dadd; +$_pin-dot-fill-hex: #1d1d1f; + +@if $o-webclient-color-scheme == dark { + $_pin-bg-hex: #22262d !global; + $_pin-key-bg-hex: #2d3138 !global; + $_pin-key-hover-hex: #3a3f48 !global; + $_pin-border-hex: #424245 !global; + $_pin-dot-fill-hex: #f5f5f7 !global; +} + +.o_fp_pin_pad { + background: $_pin-bg-hex; + border-radius: 12px; + padding: 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.8rem; + min-width: 280px; +} + +.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; } +.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; } + +.o_fp_pin_dots { + display: flex; + gap: 0.8rem; + margin: 0.5rem 0; +} + +.o_fp_pin_dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: $_pin-dot-hex; + transition: background 0.1s ease; + &.filled { background: $_pin-dot-fill-hex; } +} + +.o_fp_pin_error { + color: #ff3b30; + font-size: 0.85rem; + min-height: 1.2rem; +} + +.o_fp_pin_grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + width: 100%; +} + +.o_fp_pin_key { + background: $_pin-key-bg-hex; + border: 1px solid $_pin-border-hex; + border-radius: 10px; + padding: 1rem 0; + font-size: 1.5rem; + font-weight: 500; + cursor: pointer; + transition: background 0.1s ease, transform 0.05s ease; + + &:hover { background: $_pin-key-hover-hex; } + &:active { transform: scale(0.97); } + &:disabled { opacity: 0.5; cursor: wait; } +} + +.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); } +.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); } + +@keyframes o_fp_pin_shake_kf { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-8px); } + 50% { transform: translateX(8px); } + 75% { transform: translateX(-4px); } +} +.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; } +``` + +- [ ] **Step 4: Register assets in manifest** + +Edit `__manifest__.py` `assets.web.assets_backend`, add (after the existing component lines): + +```python + # ---- Phase 6.2 tablet PIN gate ---- + 'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss', + 'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml', + 'fusion_plating_shopfloor/static/src/js/components/pin_pad.js', + 'fusion_plating_shopfloor/static/src/js/services/tech_store.js', + 'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js', +``` + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_pad.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): FpPinPad OWL component (P6.2.2)" +``` + +--- + +### Task 6.2.3 — FpIdleWarning component + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/components/idle_warning.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss` + +- [ ] **Step 1: Create JS** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — FpIdleWarning (shared OWL service) +// +// Yellow-border overlay + countdown toast shown during the last +// (default 30) seconds before auto-lock. Any pointer/touch event on +// the document elsewhere resets the activity tracker, which causes +// this component's parent (FpTabletLock) to hide the warning. +// ============================================================================= + +import { Component } from "@odoo/owl"; + +export class FpIdleWarning extends Component { + static template = "fusion_plating_shopfloor.IdleWarning"; + static props = { + secondsRemaining: { type: Number }, + }; +} +``` + +- [ ] **Step 2: Create template** + +```xml + + + + +
+
+ + Locking in s · tap anywhere to stay +
+
+
+ +
+``` + +- [ ] **Step 3: Create SCSS** + +```scss +// ============================================================================= +// FpIdleWarning — yellow-border countdown overlay before auto-lock +// ============================================================================= + +.o_fp_idle_warning_overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + box-shadow: inset 0 0 0 4px #ff9f0a; + animation: o_fp_idle_pulse 1s ease-in-out infinite alternate; +} + +@keyframes o_fp_idle_pulse { + from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); } + to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); } +} + +.o_fp_idle_warning_toast { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + background: #1d1d1f; + color: #ffd585; + padding: 0.6rem 1.2rem; + border-radius: 8px; + font-size: 0.9rem; + z-index: 9999; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + + strong { color: #ffb84d; margin: 0 0.2rem; } + > i { margin-right: 0.4rem; } +} +``` + +- [ ] **Step 4: Register assets** + +Add to `__manifest__.py` after the pin_pad lines: + +```python + 'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss', + 'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml', + 'fusion_plating_shopfloor/static/src/js/components/idle_warning.js', +``` + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/idle_warning.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): FpIdleWarning overlay (P6.2.3)" +``` + +--- + +### Task 6.2.4 — FpTabletLock wrapper component + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` + +- [ ] **Step 1: Create JS** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — FpTabletLock (top-level wrapper) +// +// Mounted by Landing / Workspace / Manager Dashboard as their outermost +// element. Renders the lock screen (tile grid + PIN pad) when no tech +// is signed in; renders (the wrapped client +// action) otherwise. Also drives the auto-lock countdown + idle warning. +// +// Usage in a parent template: +// +// +//
...your existing tree...
+//
+// +// ============================================================================= + +import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; +import { FpPinPad } from "./components/pin_pad"; +import { FpIdleWarning } from "./components/idle_warning"; + +export class FpTabletLock extends Component { + static template = "fusion_plating_shopfloor.TabletLock"; + static components = { FpPinPad, FpIdleWarning }; + static props = { + slots: { type: Object, optional: true }, + }; + + setup() { + this.techStore = useService("fp_shopfloor_tech_store"); + this.activity = useService("fp_shopfloor_activity"); + this.notification = useService("notification"); + + this.state = useState({ + tiles: [], + selectedTileUserId: null, + idleSecondsRemaining: null, + loadingTiles: false, + }); + + onMounted(async () => { + await this._loadTiles(); + this._tick = setInterval(() => this._checkIdle(), 1000); + // Heartbeat ping every 60s — for forensic visibility + this._ping = setInterval(() => { + if (this.techStore.currentTechId) { + rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId }) + .catch(() => {}); + } + }, 60000); + }); + + onWillUnmount(() => { + if (this._tick) clearInterval(this._tick); + if (this._ping) clearInterval(this._ping); + }); + } + + get isLocked() { + return this.techStore.isLocked; + } + + async _loadTiles() { + this.state.loadingTiles = true; + try { + const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null; + const res = await rpc("/fp/tablet/tiles", { station_id: stationId }); + if (res && res.ok) { + this.state.tiles = res.tiles; + } + } finally { + this.state.loadingTiles = false; + } + } + + _checkIdle() { + if (!this.techStore.currentTechId) { + this.state.idleSecondsRemaining = null; + return; + } + const remaining = this.activity.getSecondsUntilLock(); + const warnThreshold = this.activity.getWarnThresholdSec(); + if (remaining <= 0) { + this.handOff(); + } else if (remaining <= warnThreshold) { + this.state.idleSecondsRemaining = remaining; + } else if (this.state.idleSecondsRemaining !== null) { + this.state.idleSecondsRemaining = null; + } + } + + onTileClick(userId) { + this.state.selectedTileUserId = userId; + // PIN pad renders for this user; PinPad's onSubmit calls unlock() + } + + _selectedTileName() { + const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId); + return tile ? tile.name : ""; + } + + async unlock(pin) { + try { + const res = await rpc("/fp/tablet/unlock", { + user_id: this.state.selectedTileUserId, + pin, + }); + if (res && res.ok) { + this.techStore.setTech(res.current_tech_id, res.current_tech_name); + this.activity.bump(); + this.state.selectedTileUserId = null; + return { ok: true }; + } + return { ok: false, error: (res && res.error) || "Unlock failed" }; + } catch (err) { + return { ok: false, error: err.message || String(err) }; + } + } + + onPinCancel() { + this.state.selectedTileUserId = null; + } + + handOff() { + this.techStore.lock(); + this.state.selectedTileUserId = null; + this.state.idleSecondsRemaining = null; + this._loadTiles(); + } +} +``` + +- [ ] **Step 2: Create template** + +```xml + + + + + +
+
+

Tap your name to unlock

+
+
+ Loading… +
+
+ +
+ No operators configured. +
+
+ + + +
+
+ +
+
+
+ + + + + + +
+``` + +- [ ] **Step 3: Create SCSS** + +```scss +// ============================================================================= +// FpTabletLock — lock screen with tile grid + PIN pad overlay +// ============================================================================= + +$o-webclient-color-scheme: bright !default; + +$_lock-bg-hex: #f3f4f6; +$_lock-card-hex: #ffffff; +$_lock-border-hex: #d8dadd; +$_lock-ink-hex: #1d1d1f; + +@if $o-webclient-color-scheme == dark { + $_lock-bg-hex: #1a1d21 !global; + $_lock-card-hex: #22262d !global; + $_lock-border-hex: #424245 !global; + $_lock-ink-hex: #f5f5f7 !global; +} + +.o_fp_tablet_lock { + position: fixed; + inset: 0; + background: $_lock-bg-hex; + color: $_lock-ink-hex; + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + z-index: 9000; + overflow-y: auto; +} + +.o_fp_tablet_lock_header { + h1 { + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.6rem; + } +} + +.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty { + margin: 2rem auto; + color: var(--text-secondary, #666); +} + +.o_fp_tablet_lock_tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + max-width: 900px; + width: 100%; +} + +.o_fp_tablet_lock_tile { + background: $_lock-card-hex; + border: 2px solid $_lock-border-hex; + border-radius: 12px; + padding: 1rem; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + transition: border-color 0.1s ease, transform 0.05s ease; + + &:hover { border-color: #0071e3; } + &:active { transform: scale(0.98); } +} + +.o_fp_tablet_lock_tile_avatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; +} + +.o_fp_tablet_lock_tile_name { + font-weight: 600; + text-align: center; +} + +.o_fp_tablet_lock_tile_clocked { + color: #34c759; + font-size: 0.75rem; +} + +.o_fp_tablet_lock_tile_nopin { + color: #ff9f0a; + font-size: 0.75rem; +} + +.o_fp_tablet_lock_pinwrap { + margin-top: 2rem; +} +``` + +- [ ] **Step 4: Register assets** + +In `__manifest__.py`, add after the IdleWarning lines: + +```python + 'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', + 'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml', + 'fusion_plating_shopfloor/static/src/js/tablet_lock.js', +``` + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): FpTabletLock wrapper component (P6.2.4)" +``` + +--- + +### Task 6.2.5 — Wire FpTabletLock into Landing / Workspace / Manager + Hand-Off buttons + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js` — import + components +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml` — wrap root + Hand-Off button +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js` — import + components +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml` — wrap root + Hand-Off button +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js` — import + components +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml` — wrap root + Hand-Off button + +- [ ] **Step 1: Update shopfloor_landing.js — add FpTabletLock to imports + components** + +Find the existing imports + components and add: + +```javascript +import { FpTabletLock } from "./tablet_lock"; +``` + +In `static components = {...}`, add `FpTabletLock`. + +In `setup()`, add a tech-store ref: + +```javascript +this.techStore = useService("fp_shopfloor_tech_store"); +``` + +Add a `handOff()` method: + +```javascript +handOff() { + this.techStore.lock(); +} +``` + +- [ ] **Step 2: Update shopfloor_landing.xml — wrap root in FpTabletLock + Hand-Off button** + +Wrap the entire `
...
` in a `` so the lock screen renders above when locked: + +```xml + + + +
+ +
+
+
+
+``` + +Inside the existing header's `.o_fp_landing_head_actions` block, add the Hand-Off button (before the refresh indicator): + +```xml + +``` + +- [ ] **Step 3: Repeat for job_workspace.js + .xml** + +Same shape: import `FpTabletLock`, add to components, add `techStore` + `handOff()`. Wrap the `
` in ``. Add Hand-Off button in the header's right side (next to existing pills/chips). + +- [ ] **Step 4: Repeat for manager_dashboard.js + .xml** + +Same pattern. Wrap `
`. Add Hand-Off button in `o_fp_manager_head_actions`. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml +git commit -m "feat(fusion_plating_shopfloor): wire FpTabletLock + Hand-Off into Landing/Workspace/Manager (P6.2.5)" +``` + +--- + +### Task 6.2.6 — FpPinSetup modal + client action for Preferences button + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_setup.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml` + +- [ ] **Step 1: Create JS** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`) +// +// Modal flow for setting OR changing the user's tablet PIN. Triggered +// from res.users preferences via action_open_tablet_pin_setup. Three +// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new. +// ============================================================================= + +import { Component, useState, onMounted } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { FpPinPad } from "./pin_pad"; + +export class FpPinSetup extends Component { + static template = "fusion_plating_shopfloor.PinSetup"; + static components = { FpPinPad }; + static props = ["*"]; + + setup() { + this.notification = useService("notification"); + this.action = useService("action"); + this.user = useService("user"); + this.state = useState({ + stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done' + newPin: "", + hasExistingPin: false, + }); + onMounted(() => this._init()); + } + + async _init() { + // Check whether the user currently has a hash via a cheap RPC + const has = await rpc("/web/dataset/call_kw", { + model: "res.users", + method: "search_count", + args: [[ + ["id", "=", this.user.userId], + ["x_fc_tablet_pin_set_date", "!=", false], + ]], + kwargs: {}, + }); + this.state.hasExistingPin = has > 0; + this.state.stage = this.state.hasExistingPin ? "old" : "new"; + } + + async onOldPinSubmit(pin) { + // We don't verify the OLD pin separately — set_pin verifies it + // server-side. Stash it for the final call. + this._oldPin = pin; + this.state.stage = "new"; + return { ok: true }; + } + + async onNewPinSubmit(pin) { + this.state.newPin = pin; + this.state.stage = "confirm"; + return { ok: true }; + } + + async onConfirmPinSubmit(pin) { + if (pin !== this.state.newPin) { + return { ok: false, error: "PINs don't match. Try again." }; + } + const params = { new_pin: this.state.newPin }; + if (this._oldPin) params.old_pin = this._oldPin; + const res = await rpc("/fp/tablet/set_pin", params); + if (res && res.ok) { + this.notification.add("Tablet PIN updated.", { type: "success" }); + this.state.stage = "done"; + setTimeout(() => this._close(), 1500); + return { ok: true }; + } + // Reset back to start on hard error so user can retry cleanly + this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" }); + this._oldPin = null; + this.state.newPin = ""; + this.state.stage = this.state.hasExistingPin ? "old" : "new"; + return { ok: false, error: (res && res.error) || "Failed" }; + } + + _close() { + this.action.doAction({ type: "ir.actions.act_window_close" }); + } + + onCancel() { + this._close(); + } +} + +registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup); +``` + +- [ ] **Step 2: Create template** + +```xml + + + + +
+
+ Loading… +
+ + + +
+ +

PIN updated

+
+
+
+ +
+``` + +- [ ] **Step 3: Register assets** + +In `__manifest__.py`, add after the tablet_lock lines: + +```python + 'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml', + 'fusion_plating_shopfloor/static/src/js/components/pin_setup.js', +``` + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/pin_setup.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): FpPinSetup modal + fp_tablet_pin_setup action (P6.2.6)" +``` + +--- + +### Task 6.2.7 — Phase 6.2 version bump + deploy + smoke test + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Bump version to 19.0.30.1.0** + +- [ ] **Step 2: Commit + tag + push** + +```bash +git add fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "chore(fusion_plating_shopfloor): bump 19.0.30.1.0 for Phase 6.2 — lock screen" +git tag -f phase6_2-lock-screen +git push origin main phase6_2-lock-screen +``` + +- [ ] **Step 3: Deploy on entech** + +```bash +cd /k/Github/Odoo-Modules/fusion_plating +tar -czf - --exclude='__pycache__' --exclude='*.pyc' fusion_plating_shopfloor | \ + ssh pve-worker5 'pct exec 111 -- bash -c "cd /mnt/extra-addons/custom && tar -xzf -"' + +ssh pve-worker5 'pct exec 111 -- bash -c "systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --stop-after-init\" && systemctl start odoo"' + +# Clear asset cache so the new OWL bundles compile fresh +ssh pve-worker5 'pct exec 111 -- bash -c "su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\""' +``` + +- [ ] **Step 4: Smoke test on entech** + +1. Open https://enplating.com → log in → Plating → Shop Floor → Workstation. +2. Expected: tile grid appears with operator avatars (or "No operators configured" if the operator group is empty). +3. Click an operator who has no PIN → "PIN required" message under their name. Click the tile anyway → expect graceful "Set one in Preferences first" message. +4. Set a PIN via Preferences → modal opens, PinPad renders, set 1234 → confirm 1234 → "PIN updated" → close. +5. Back on Workstation lock screen → tap your tile → PinPad → enter 1234 → unlocked, Workstation renders. +6. Wait 5 minutes idle without touching → yellow border + "Locking in 30s" → 30s countdown → tile grid returns. +7. Tap Hand-Off button in header while in Workspace → confirm → tile grid returns immediately. +8. Enter PIN wrong 5 times → 6th attempt shows lockout message; tile other tech to confirm they can still unlock (per-user lockout, not per-tablet). + +--- + +# Phase 6.3 — Audit kwarg propagation + +**Goal:** Existing shop-floor action endpoints accept an optional `tablet_tech_id` kwarg from the OWL components. Server uses `env.with_user(tech_id)` so chatter posts + write_uid records carry the right operator identity. + +--- + +### Task 6.3.1 — fpRpc wrapper + server-side helper + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/services/fp_rpc.js` +- Create: `fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py` — helper utility for controllers + +- [ ] **Step 1: Create the client-side wrapper** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — fpRpc() wrapper +// +// Drop-in replacement for the standard `rpc()` import. Automatically +// injects the current tablet_tech_id from the tech_store into every +// call, so server-side endpoints can attribute the action to the right +// user via env.with_user(). +// +// USE for any RPC that WRITES (start step, finish step, hold create, +// sign-off, milestone advance). For read-only loads (kanban, workspace +// load, manager funnel), plain rpc() is fine. +// +// Example: +// import { fpRpc } from "../services/fp_rpc"; +// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId }); +// +// ============================================================================= + +import { rpc as baseRpc } from "@web/core/network/rpc"; + +let _techStore = null; + +function _getTechStore() { + if (_techStore) return _techStore; + // Lazy-load to avoid circular service init issues + try { + const env = odoo.__WOWL_DEBUG__?.root?.env; + if (env && env.services && env.services.fp_shopfloor_tech_store) { + _techStore = env.services.fp_shopfloor_tech_store; + } + } catch (e) { + // ignore — defensive against debug API not being available + } + return _techStore; +} + +export function fpRpc(url, params = {}) { + const techStore = _getTechStore(); + if (techStore && techStore.currentTechId) { + params = { ...params, tablet_tech_id: techStore.currentTechId }; + } + return baseRpc(url, params); +} +``` + +- [ ] **Step 2: Create the server-side helper** + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Helper for audit-credit propagation (Phase 6.3 tablet redesign). + +Controllers that accept an optional `tablet_tech_id` kwarg use this +helper to switch their `env` to the tech-of-record before performing +writes. The result: chatter posts + create_uid/write_uid carry the +unlocked tech's identity, not the tablet's persistent session user. +""" +import logging + +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def env_for_tablet_tech(env, tablet_tech_id): + """Return an env scoped to `tablet_tech_id` if it's a valid user; + otherwise return the original env unchanged. + + Validation: the user must exist and be active. We deliberately do + NOT cross-check that they actually unlocked recently — the OWL + component is the source of truth for "who's at the tablet right + now", and the only path that produces a tablet_tech_id is a + successful /fp/tablet/unlock followed by an active session in the + OWL tech_store. + """ + if not tablet_tech_id: + return env + try: + tech_id = int(tablet_tech_id) + except (TypeError, ValueError): + return env + User = env['res.users'].sudo() + tech = User.browse(tech_id) + if not tech.exists() or not tech.active: + _logger.warning( + "tablet_tech_id %s invalid (not found or inactive); " + "falling back to session uid %s", + tablet_tech_id, env.uid, + ) + return env + return env(user=tech_id) +``` + +- [ ] **Step 3: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/services/fp_rpc.js \ + fusion_plating/fusion_plating_shopfloor/controllers/_tablet_audit.py +git commit -m "feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)" +``` + +--- + +### Task 6.3.2 — Wire `tablet_tech_id` into workspace endpoints + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py` + +- [ ] **Step 1: Import the helper at the top of the file** + +Add to the imports section: + +```python +from odoo.addons.fusion_plating_shopfloor.controllers._tablet_audit import env_for_tablet_tech +``` + +- [ ] **Step 2: Accept tablet_tech_id on each ACTION endpoint and rebind env** + +For each of `hold`, `sign_off`, `advance_milestone` (the action endpoints — NOT `load` which is read-only), change the signature to add `tablet_tech_id=None` and rebind env at the top: + +```python + @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, tablet_tech_id=None): + env = env_for_tablet_tech(request.env, tablet_tech_id) + # ... rest of body uses `env` instead of `request.env` +``` + +Repeat for `sign_off`: + +```python + @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') + def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None): + env = env_for_tablet_tech(request.env, tablet_tech_id) + # ... use env throughout +``` + +And `advance_milestone`: + +```python + @http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user') + def advance_milestone(self, job_id, tablet_tech_id=None): + env = env_for_tablet_tech(request.env, tablet_tech_id) + # ... use env throughout +``` + +In each body, replace `request.env` with `env` everywhere the function does writes. `request.env` is fine for read-only queries; the audit-relevant calls are `Hold.create(...)`, `step.button_finish()`, `job.action_advance_next_milestone()`. + +- [ ] **Step 3: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py +git commit -m "feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)" +``` + +--- + +### Task 6.3.3 — Wire `tablet_tech_id` into shopfloor action endpoints + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py` + +- [ ] **Step 1: Import the helper** + +Same import line as Task 6.3.2. + +- [ ] **Step 2: Add `tablet_tech_id=None` to each action endpoint** + +The action endpoints in shopfloor_controller (the ones that WRITE): +- `start_wo` +- `stop_wo` +- `start_bake` +- `end_bake` +- `bump_qty_done` +- `bump_qty_scrapped` +- `log_chemistry` +- `log_thickness_reading` +- `quality_hold` +- `mark_gate` + +Read-only endpoints (`scan`, `tablet_overview`, `plant_overview`, `queue`) DON'T need the kwarg. + +For each, add `tablet_tech_id=None` to the signature and put this line at the top of the body before any write: + +```python +env = env_for_tablet_tech(request.env, tablet_tech_id) +``` + +Then replace `request.env` with `env` in the write paths (step.button_start(), step.button_finish(), Hold.create(), Reading.create(), etc.). + +- [ ] **Step 3: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py +git commit -m "feat(fusion_plating_shopfloor): wire tablet_tech_id into shopfloor action endpoints (P6.3.3)" +``` + +--- + +### Task 6.3.4 — Wire `tablet_tech_id` into manager action endpoints + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py` + +- [ ] **Step 1: Apply the same pattern to manager's WRITE endpoints** + +Action endpoints in manager_controller: +- `assign_worker` +- `assign_tank` +- `take_over` + +Read endpoints (`overview`, `funnel`, `approval_inbox`, `at_risk`) don't need it. + +Same pattern: add `tablet_tech_id=None`, rebind `env`, swap `request.env` for `env` in writes. + +- [ ] **Step 2: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +git commit -m "feat(fusion_plating_shopfloor): wire tablet_tech_id into manager action endpoints (P6.3.4)" +``` + +--- + +### Task 6.3.5 — Update OWL clients to use fpRpc for action calls + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js` +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js` +- Modify: `fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js` + +- [ ] **Step 1: Replace `rpc` import with `fpRpc` for action calls** + +In each file, add the import: + +```javascript +import { fpRpc } from "./services/fp_rpc"; +``` + +(Keep the existing `import { rpc } from "@web/core/network/rpc";` — we still use plain `rpc` for read-only loads.) + +In each component, search for every `rpc(` call. For calls to ACTION endpoints (anything that writes — start_wo, stop_wo, hold, sign_off, advance_milestone, assign_worker, assign_tank, take_over, bump_qty_done, bump_qty_scrapped, log_thickness_reading, log_chemistry, mark_gate, start_bake, end_bake, plant_overview/move_card), replace `rpc(` with `fpRpc(`. + +For read-only loads (workspace/load, landing/kanban, manager/overview, manager/funnel, manager/approval_inbox, manager/at_risk, tablet/tiles), keep `rpc(`. + +- [ ] **Step 2: Commit** + +```bash +git add fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js \ + fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \ + fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js +git commit -m "feat(fusion_plating_shopfloor): clients use fpRpc for action calls (P6.3.5)" +``` + +--- + +### Task 6.3.6 — Phase 6.3 version bump + deploy + smoke test + +**Files:** +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Bump version to 19.0.30.2.0** + +- [ ] **Step 2: Commit + tag + push** + +```bash +git add fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "chore(fusion_plating_shopfloor): bump 19.0.30.2.0 for Phase 6.3 — audit kwarg" +git tag -f phase6_3-audit-kwarg +git push origin main phase6_3-audit-kwarg +``` + +- [ ] **Step 3: Deploy on entech** + +(Same tar-pipe + -u + asset-cache-clear pattern as before.) + +- [ ] **Step 4: Smoke test audit propagation** + +1. Tech A unlocks tablet → opens a WO → starts a step. +2. Tech A taps Hand Off → tile screen. +3. Tech B unlocks → finishes the step. +4. Open the WO in back-office → chatter should show: + - "Step 5 started by Tech A at HH:MM:SS" + - "Step 5 finished by Tech B at HH:MM:SS" +5. SQL spot-check: `SELECT write_uid FROM fp_job_step WHERE id = ;` returns Tech B's uid, not the tablet's session uid. + +--- + +## Self-Review + +**Spec coverage check:** + +- §4.1 Lock screen tile grid → P6.1.4 (endpoint), P6.2.4 (UI) +- §4.2 PIN pad → P6.2.2 +- §4.3 Hand-Off button → P6.2.5 +- §4.4 Idle warning → P6.2.3 +- §4.5 Session continuity → P6.2.4 (`FpTabletLock` only re-mounts on full lock; OWL state preserved otherwise. SignaturePad throw-away handled by `state.selectedTileUserId` reset.) +- §4.6 Profile prefs → P6.1.7 + P6.2.6 +- §4.7 Manager reset → P6.1.7 +- §5.1 res.users fields → P6.1.1 +- §5.2 Station extras → P6.1.6 +- §5.3 ir.config_parameter defaults → P6.1.6 +- §5.4 All 5 endpoints → P6.1.2 (set/reset), P6.1.3 (unlock), P6.1.4 (tiles), P6.1.5 (ping) +- §5.5 Hash algorithm → P6.1.1 +- §5.6 Audit propagation → P6.3.1-P6.3.5 +- §6 Frontend → P6.2.1-P6.2.7 +- §7 Edge cases (no PIN, manager reset, network drop, mid-job lock, lockout per-user) → all handled in P6.1.3 and P6.2.4 +- §8 Testing → tests in P6.1.1-P6.1.4 +- §9 Build sequence → matches plan structure +- §10 Backwards compat → optional kwarg in P6.3 keeps old callers working +- §11 Rollback → version bumps allow per-phase rollback + +**Placeholder scan:** No TBD/TODO/"add validation" patterns. Every step has actual code or a concrete entech command. + +**Type consistency:** `currentTechId` (camelCase) used consistently in JS; `current_tech_id` (snake_case) used consistently in JSON payloads; `tablet_tech_id` is the canonical kwarg name everywhere across server + client. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-22-shopfloor-pin-gate-plan.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?**