cleanup(shopfloor): session_swap is the only tablet flow

Frontend cleanup completing Phase G of the tablet PIN session
redesign:

- tablet_lock.js: removed sessionMode branching (no legacy path).
  unlock() always calls /fp/tablet/unlock_session + reloads.
  handOff() always calls tabletSessionManager.lockBack('manual').
  isLocked uses currentUid vs kioskUid exclusively. _checkIdle
  still drives the warning UI via activity_tracker; the actual
  lock RPC is owned by tablet_session_manager.

- fp_rpc.js: simplified to a thin async pass-through around @web/core
  network rpc. tech_store-based tablet_tech_id injection is gone
  (the session uid IS the tech).

- tech_store.js: DELETED (replaced by per-session backend attribution
  + tablet_session_manager for lock state). Removed from manifest.

- Wrapper components (shopfloor_landing, job_workspace,
  manager_dashboard, plant_kanban): swapped useService('fp_shopfloor_tech_store')
  for useService('fp_tablet_session_manager'); techStore.lock() ->
  tabletSessionManager.lockBack('manual'). plant_kanban's defensive
  try/catch on the tech_store lookup is no longer needed.

- tablet_lock.xml: Hand-Off button no longer gated on sessionMode;
  always rendered.

- Tests: removed legacy TestTabletUnlock class from test_tablet_pin.py
  (covered the deleted /fp/tablet/unlock route). Dropped session_mode
  assertion from test_tiles_bootstrap_fields.py (the return key is
  gone post-Phase-G). kiosk_uid + current_uid assertions retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 14:36:12 -04:00
parent d9f2983ea7
commit 67fc22237b
11 changed files with 67 additions and 280 deletions

View File

@@ -86,13 +86,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
# ---- Phase 6.2 tablet PIN gate ----
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
# ---- Tablet PIN gate ----
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
# Phase D — tablet session manager (idle + ceiling timer).
# Used by tablet_lock when fp.shopfloor.tablet_session_mode
# ='session_swap' to call /fp/tablet/lock_session and reload
# the page so the browser re-bootstraps under the kiosk.
# Tablet session manager (idle + ceiling timer). Calls
# /fp/tablet/lock_session and reloads the page so the
# browser re-bootstraps under the kiosk.
'fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js',
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
# (job_workspace, shopfloor_landing, manager_dashboard,

View File

@@ -37,7 +37,7 @@ export class FpJobWorkspace extends Component {
this.notification = useService("notification");
this.action = useService("action");
this.dialog = useService("dialog");
this.techStore = useService("fp_shopfloor_tech_store");
this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({
data: null,
@@ -105,7 +105,7 @@ export class FpJobWorkspace extends Component {
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
this.tabletSessionManager.lockBack("manual");
}
onJumpToBlocker({ model, id }) {

View File

@@ -28,7 +28,7 @@ export class ManagerDashboard extends Component {
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({
overview: null,
@@ -153,7 +153,7 @@ export class ManagerDashboard extends Component {
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
this.tabletSessionManager.lockBack("manual");
}
toggleCard(jobId) {

View File

@@ -40,12 +40,7 @@ export class FpPlantKanban extends Component {
setup() {
this.notification = useService("notification");
this.action = useService("action");
// techStore may not be registered until first PIN unlock; guard with try.
try {
this.techStore = useService("fp_shopfloor_tech_store");
} catch {
this.techStore = null;
}
this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({
mode: "station",
@@ -142,9 +137,7 @@ export class FpPlantKanban extends Component {
}
onHandOff() {
if (this.techStore && this.techStore.lock) {
this.techStore.lock();
}
this.tabletSessionManager.lockBack("manual");
}
onScanQr() {

View File

@@ -1,74 +1,15 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — fpRpc() wrapper
// fpRpc — thin wrapper around @web/core/network/rpc
//
// 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() (see env_for_tablet_tech in
// controllers/_tablet_audit.py).
//
// 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 });
//
// Phase D — in `session_swap` mode, the tablet operates on a REAL Odoo
// session minted by /fp/tablet/unlock_session whose uid IS the tech.
// In that mode tablet_tech_id is redundant; the server attributes the
// write to request.env.user directly. We cache the mode at module
// level (refresh on every page load, exactly when the session may
// have flipped).
// Post-Phase-G of the tablet PIN session redesign: this no longer
// injects tablet_tech_id (the session uid IS the tech). Kept as a
// thin pass-through for backwards compatibility with callers that
// import fpRpc; a future cleanup could remove the wrapper entirely
// and update callers to use `rpc` directly.
// =============================================================================
import { rpc as baseRpc } from "@web/core/network/rpc";
// Cached once per page load. Invalidated naturally by window.location.reload()
// after every lock/unlock (the JS bundle reinitializes, cache resets to null).
let _sessionModeCache = null; // 'legacy' | 'session_swap' | null (unknown)
async function _getSessionMode() {
if (_sessionModeCache !== null) return _sessionModeCache;
try {
const res = await baseRpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_session_mode", "legacy"],
kwargs: {},
});
_sessionModeCache = res || "legacy";
} catch (e) {
// If the lookup fails (network blip, ACL change), fail SAFE
// to legacy — that keeps tablet_tech_id injection on so the
// server-side audit attribution still works.
_sessionModeCache = "legacy";
}
return _sessionModeCache;
}
function _getTechStore() {
// Lazy-resolve via the global debug API — avoids circular service init
try {
const env = odoo.__WOWL_DEBUG__?.root?.env;
if (env && env.services && env.services.fp_shopfloor_tech_store) {
return env.services.fp_shopfloor_tech_store;
}
} catch (e) {
// ignore
}
return null;
}
import { rpc } from "@web/core/network/rpc";
export async function fpRpc(url, params = {}) {
const mode = await _getSessionMode();
if (mode !== "session_swap") {
const techStore = _getTechStore();
if (techStore && techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
}
return baseRpc(url, params);
return rpc(url, params);
}

View File

@@ -1,42 +0,0 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tech Store (shared OWL service)
//
// Holds the "current tech of record" for the locked tablet. Set by
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
// and pass it through fpRpc() so server actions credit the right user.
// =============================================================================
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const fpShopfloorTechStore = {
start() {
const state = reactive({
currentTechId: null,
currentTechName: "",
lockedAt: null,
});
return {
get currentTechId() { return state.currentTechId; },
get currentTechName() { return state.currentTechName; },
get isLocked() { return !state.currentTechId; },
setTech(id, name) {
state.currentTechId = id;
state.currentTechName = name;
state.lockedAt = null;
},
lock() {
state.currentTechId = null;
state.currentTechName = "";
state.lockedAt = Date.now();
},
state, // exposed for OWL reactive subscriptions
};
},
};
registry
.category("services")
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);

View File

@@ -38,7 +38,7 @@ export class FpShopfloorLanding extends Component {
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant",
@@ -126,7 +126,7 @@ export class FpShopfloorLanding extends Component {
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
// Tech walking away: lock the tablet so the next operator must PIN in
this.techStore.lock();
this.tabletSessionManager.lockBack("manual");
}
// ---- Search ------------------------------------------------------------

View File

@@ -31,11 +31,11 @@ export class FpTabletLock extends Component {
};
setup() {
this.techStore = useService("fp_shopfloor_tech_store");
this.activity = useService("fp_shopfloor_activity");
this.notification = useService("notification");
// Phase D: idle + ceiling timer for session_swap mode. Started
// once tiles bootstrap shows we're already on a tech session.
// Post-Phase-G: session_swap is the only flow. The tablet
// session manager owns the actual idle-lock RPC; activity
// tracker drives the warning countdown UI.
this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({
@@ -49,8 +49,9 @@ export class FpTabletLock extends Component {
clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()),
company: null,
// Phase D — feature flag + kiosk identity from bootstrap
sessionMode: "legacy", // 'legacy' or 'session_swap'
// Kiosk identity from bootstrap so we can tell when the
// current browser session belongs to a tech (= unlocked) vs.
// the kiosk (= locked).
kioskUid: null,
currentUid: null,
});
@@ -58,11 +59,13 @@ export class FpTabletLock extends Component {
onMounted(async () => {
await this._loadTiles();
this._tick = setInterval(() => this._checkIdle(), 1000);
// Heartbeat ping every 60s — for forensic visibility
// Heartbeat ping every 60s — for forensic visibility. Only
// ping while a tech is logged in; on the kiosk session this
// is just noise.
this._ping = setInterval(() => {
if (this.techStore.currentTechId) {
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
.catch(() => {});
if (this.state.currentUid
&& this.state.currentUid !== this.state.kioskUid) {
rpc("/fp/tablet/ping", {}).catch(() => {});
}
}, 60000);
// Clock tick — update visible HH:MM and date label every 60s.
@@ -72,12 +75,11 @@ export class FpTabletLock extends Component {
this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now);
}, 60000);
// Session-swap mode: if we're already on a TECH session (uid
// != kiosk), start the idle/ceiling timer immediately. This
// handles the case where the page was reloaded after
// unlock_session minted the tech's session.
if (this.state.sessionMode === "session_swap"
&& this.state.currentUid
// If we're already on a TECH session (uid != kiosk), start
// the idle/ceiling timer immediately. This handles the case
// where the page was reloaded after unlock_session minted
// the tech's session.
if (this.state.currentUid
&& this.state.currentUid !== this.state.kioskUid) {
this.tabletSessionManager.beginSession();
}
@@ -92,14 +94,10 @@ export class FpTabletLock extends Component {
}
get isLocked() {
// SESSION-SWAP MODE: the BROWSER session itself tells us whether
// a tech is unlocked — current_uid != kiosk_uid means unlocked.
// LEGACY MODE: defer to the techStore client-side flag.
if (this.state.sessionMode === "session_swap") {
return !this.state.currentUid
|| this.state.currentUid === this.state.kioskUid;
}
return this.techStore.isLocked;
// The browser session itself tells us whether a tech is
// unlocked — current_uid != kiosk_uid means unlocked.
return !this.state.currentUid
|| this.state.currentUid === this.state.kioskUid;
}
async _loadTiles() {
@@ -109,9 +107,6 @@ export class FpTabletLock extends Component {
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) {
this.state.company = res.company || null;
// Phase D — capture session_mode + kiosk/current uids so
// unlock() / isLocked / handOff can branch on mode.
this.state.sessionMode = res.tablet_session_mode || "legacy";
this.state.kioskUid = res.kiosk_uid || null;
this.state.currentUid = res.current_uid || null;
// Decorate each tile with an animation-delay (50ms staggered,
@@ -130,14 +125,12 @@ export class FpTabletLock extends Component {
}
_checkIdle() {
// In session_swap mode, the tablet_session_manager owns the idle
// timer (it polls every 5s and calls /fp/tablet/lock_session
// directly). Skip this legacy 1s-poll path to avoid two parallel
// idle systems competing on the same tech session.
if (this.state.sessionMode === "session_swap") {
return;
}
if (!this.techStore.currentTechId) {
// Activity tracker drives the warning countdown UI; the actual
// idle-lock RPC is owned by tablet_session_manager (it polls
// every 5s and calls /fp/tablet/lock_session directly). This
// path just updates the visible "auto-lock in N seconds" banner.
if (!this.state.currentUid
|| this.state.currentUid === this.state.kioskUid) {
this.state.idleSecondsRemaining = null;
return;
}
@@ -163,36 +156,19 @@ export class FpTabletLock extends Component {
async unlock(pin) {
try {
// SESSION-SWAP MODE: call the new endpoint, then reload the
// page so the browser re-bootstraps under the tech's session.
if (this.state.sessionMode === "session_swap") {
const res = await rpc("/fp/tablet/unlock_session", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
// Cookie has swapped. Reload so OWL/services re-init
// under the new (tech) session. The session manager
// (Task D1) picks up on the next page load.
// Match the legacy path's cleanup before reload kicks in.
this.state.selectedTileUserId = null;
window.location.reload();
// Return a pending state so the caller doesn't try to
// navigate while we're tearing down.
return { ok: true, reloading: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
}
// LEGACY MODE: existing /fp/tablet/unlock path
const res = await rpc("/fp/tablet/unlock", {
const res = await rpc("/fp/tablet/unlock_session", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
this.activity.bump();
// Cookie has swapped. Reload so OWL/services re-init
// under the new (tech) session. The session manager
// picks up on the next page load.
this.state.selectedTileUserId = null;
return { ok: true };
window.location.reload();
// Return a pending state so the caller doesn't try to
// navigate while we're tearing down.
return { ok: true, reloading: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
} catch (err) {
@@ -205,17 +181,9 @@ export class FpTabletLock extends Component {
}
handOff() {
// SESSION-SWAP MODE: the server destroys the tech session, then
// we reload to re-bootstrap as the kiosk.
if (this.state.sessionMode === "session_swap") {
this.tabletSessionManager.lockBack("manual");
return;
}
// LEGACY MODE: client-side state flip only.
this.techStore.lock();
this.state.selectedTileUserId = null;
this.state.idleSecondsRemaining = null;
this._loadTiles();
// Server destroys the tech session, then we reload to
// re-bootstrap as the kiosk.
this.tabletSessionManager.lockBack("manual");
}
// === 2026-05-24 redesign helpers =====================================

View File

@@ -75,16 +75,10 @@
<t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/>
<!-- Phase D: floating Hand-Off button for session_swap mode.
In legacy mode the wrapper components (landing/workspace/
manager) own their own Hand-Off buttons that hit
techStore.lock(). In session_swap mode those become
no-ops (techStore isn't the source of truth anymore),
so we render a tablet_lock-owned button that calls
our own handOff() which routes through the session
manager + page reload. -->
<button t-if="state.sessionMode === 'session_swap'"
class="o_fp_lock_handoff_btn btn btn-warning"
<!-- Floating Hand-Off button. Post-Phase-G session_swap is
the only flow; the button routes through the tablet
session manager + page reload. -->
<button class="o_fp_lock_handoff_btn btn btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off

View File

@@ -126,63 +126,6 @@ class TestTabletResetPinFor(HttpCase):
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletUnlock(HttpCase):
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
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.sudo().set_tablet_pin('1234')
def _unlock(self, pin):
return _rpc(self, '/fp/tablet/unlock',
user_id=self.target.id, pin=pin)
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.assertTrue(res.get('needs_setup'))
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletTiles(HttpCase):
"""P6.1.4 — /fp/tablet/tiles endpoint."""

View File

@@ -3,10 +3,11 @@ from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fp_tablet')
class TestTilesBootstrapFields(HttpCase):
"""Phase D added 3 bootstrap fields to /fp/tablet/tiles:
tablet_session_mode, kiosk_uid, current_uid. The OWL lock screen
branches on these, so a contract regression would silently
break the session-swap flow."""
"""Post-Phase-G /fp/tablet/tiles returns kiosk_uid + current_uid so
the OWL lock screen can detect whether the current browser session
belongs to the kiosk (locked) or a tech (unlocked). A contract
regression on either field would silently break the session-swap
flow."""
def setUp(self):
super().setUp()
@@ -25,15 +26,6 @@ class TestTilesBootstrapFields(HttpCase):
headers={'Content-Type': 'application/json'},
).json()
def test_tiles_returns_session_mode(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'tiles_test_pwd')
resp = self._jsonrpc('/fp/tablet/tiles', {})
result = resp.get('result', {})
self.assertIn('tablet_session_mode', result,
'tiles bootstrap must include tablet_session_mode for OWL branching')
# Default value is 'legacy' until rollout flips the flag
self.assertIn(result['tablet_session_mode'], ('legacy', 'session_swap'))
def test_tiles_returns_kiosk_uid(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'tiles_test_pwd')
resp = self._jsonrpc('/fp/tablet/tiles', {})