From 80f80fb707e2af9c8a5a091c1232dd831a5f90f9 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 20:31:25 -0400 Subject: [PATCH] fix(tablet): ACL, loading hang, timer offset + FP-tz clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes shipped together — all surfaced during tablet UX walkthrough on entech. 1. sale.order ACL on step completion Technicians hit "Access Denied... sale.order" when starting/finishing any step. _fp_check_receiving_gate + the serial-promotion helpers + _fp_resolve_contract_review_part read step.job_id.sale_order_id (and sale_order_line_ids) without sudo. Per Rule 13m, denormalized cross- module reads in tablet controllers must sudo() the source recordset. 2. Workspace stuck on "Loading Job Workspace…" after Hand Off + relogin Action params aren't URL-encoded, so the workspace remounts with jobId=null. refresh() exited early, state.data stayed null, "Loading" shown indefinitely. onMounted now redirects to the plant kanban when jobId is null or the initial load returns no data. 3. 4-hour timer offset on active steps workspace_controller used fp_format() to serialize date_started — which converts naive UTC to user tz wall time first. JS then appended 'Z' and parsed as UTC, so timer was offset by the user's tz (4h on EDT). Switched to fp_isoformat_utc() (proper +00:00 ISO) and dropped the Z-append in formatActiveStepElapsed + isActiveStepOvertime. 4. Lock-screen clock follows FP regional setting tablet_lock.js used d.getHours() / d.toLocaleDateString() — browser tz. Now /fp/tablet/tiles returns tz_name (fp_user_tz resolution: user.tz → company.x_fc_default_tz → UTC) and the formatters use Intl.DateTimeFormat with the explicit timeZone option. plant_overview now consumes server_time (already fp_format'd) instead of toLocaleTime String. Same chain Odoo backend uses, so PDF / view / tablet all agree on what time it is. Versions: fusion_plating_jobs 19.0.10.30.0, fusion_plating_shopfloor 19.0.33.1.12. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/fp_job_step.py | 15 +++-- .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/tablet_controller.py | 7 +++ .../controllers/workspace_controller.py | 11 ++-- .../static/src/js/job_workspace.js | 37 ++++++++++-- .../static/src/js/plant_overview.js | 7 ++- .../static/src/js/tablet_lock.js | 58 ++++++++++++------- 8 files changed, 102 insertions(+), 37 deletions(-) diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 0134b2e0..0ad8cfb3 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.10.29.0', + 'version': '19.0.10.30.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 07f3b597..77c87bd2 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -889,7 +889,8 @@ class FpJobStep(models.Model): skipped. """ for step in self: - job = step.job_id + # sudo() — technicians lack sale.order ACL (Rule 13m). + job = step.sudo().job_id if not job.sale_order_line_ids: continue serials = job.sale_order_line_ids.mapped('x_fc_serial_ids') @@ -909,7 +910,8 @@ class FpJobStep(models.Model): in-flight serials to `inspected` so the shipper sees them ready for packing. Conservative — only promotes from `in_process`.""" for step in self: - job = step.job_id + # sudo() — technicians lack sale.order ACL (Rule 13m). + job = step.sudo().job_id if not job.sale_order_line_ids: continue # Is this the highest-sequence non-cancelled step on the job? @@ -964,7 +966,8 @@ class FpJobStep(models.Model): Falls through to None when no part can be resolved (no SO line, SO line without x_fc_part_catalog_id, etc.).""" self.ensure_one() - for so_line in self.job_id.sale_order_line_ids: + # sudo() — technicians lack sale.order ACL (Rule 13m). + for so_line in self.sudo().job_id.sale_order_line_ids: if (so_line.x_fc_part_catalog_id and 'fp.contract.review' in self.env): return so_line.x_fc_part_catalog_id @@ -1165,7 +1168,11 @@ class FpJobStep(models.Model): for step in self: if step._fp_is_contract_review_step(): continue - so = step.job_id.sale_order_id + # sudo() — technicians don't have sale.order ACL but the + # gate's purpose is checking a denormalized state field. + # Rule 13m: cross-module reads in tablet/floor controllers + # must sudo() the source recordset. + so = step.sudo().job_id.sale_order_id if not so: # Internal rework / no SO — gate doesn't apply. continue diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 9e629974..6c6e3a64 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.33.1.9', + 'version': '19.0.33.1.12', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py index 760fe3c4..8aa9e4b7 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py @@ -16,6 +16,7 @@ import logging from datetime import timedelta from odoo import _, fields, http +from odoo.addons.fusion_plating.models.fp_tz import fp_user_tz from odoo.exceptions import AccessDenied, UserError from odoo.http import request @@ -455,12 +456,18 @@ class FpTabletController(http.Controller): raise_if_not_found=False, ) kiosk_uid = kiosk.id if kiosk else None + # tz_name — resolved per fp_tz.fp_user_tz (kiosk user.tz → company + # x_fc_default_tz → UTC). Frontend uses Intl.DateTimeFormat with + # this tz so the lock-screen clock follows the FP regional + # setting, not the iPad's system tz (caught 2026-05-25 when the + # workspace timer-offset bug surfaced the broader pattern). return { 'ok': True, 'company': _lock_company_payload(request.env), 'tiles': tiles, 'kiosk_uid': kiosk_uid, 'current_uid': request.env.uid, + 'tz_name': str(fp_user_tz(request.env)), } # ====================================================================== diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py index 78e37f0a..7b71e3a8 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py @@ -20,7 +20,7 @@ Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan import logging from odoo import fields, http -from odoo.addons.fusion_plating.models.fp_tz import fp_format +from odoo.addons.fusion_plating.models.fp_tz import fp_format, fp_isoformat_utc from odoo.exceptions import UserError from odoo.http import request @@ -81,9 +81,12 @@ class FpWorkspaceController(http.Controller): 'work_centre_name': step.work_centre_id.name or '', 'duration_actual': step.duration_actual or 0, 'duration_expected': step.duration_expected or 0, - 'date_started_iso': fp_format( - env, step.date_started, fmt='%Y-%m-%d %H:%M:%S', - ) if step.date_started else '', + # fp_isoformat_utc — preserves UTC with explicit +00:00 + # offset so the JS timer parses it as UTC (not local wall + # time). fp_format would convert to user tz first, then + # the JS would re-interpret that wall time as UTC and + # offset the timer by the user's tz offset (4h on EDT). + 'date_started_iso': fp_isoformat_utc(step.date_started), 'instructions': step.instructions or '', 'thickness_target': step.thickness_target or 0, 'thickness_uom': step.thickness_uom or '', diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js index f5b65a58..15466b26 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -56,7 +56,32 @@ export class FpJobWorkspace extends Component { const params = (this.props.action && this.props.action.params) || {}; this.state.jobId = params.job_id || null; this.state.focusStepId = params.focus_step_id || null; + // After Hand Off + PIN relogin the action remounts without + // params (action params aren't URL-encoded), so jobId is + // null and refresh() exits early — workspace was stuck on + // "Loading Job Workspace…" indefinitely. Fall back to the + // plant kanban so the operator lands somewhere usable. + if (!this.state.jobId) { + this.action.doAction({ + type: "ir.actions.client", + tag: "fp_plant_kanban", + target: "current", + }); + return; + } await this.refresh(); + // If load failed (job no longer accessible, server error, etc.) + // also redirect — leaving the user on a perma-loading screen + // with no recovery path is worse than dropping them at the + // kanban where they can re-enter. + if (!this.state.data) { + this.action.doAction({ + type: "ir.actions.client", + tag: "fp_plant_kanban", + target: "current", + }); + return; + } this._refreshInterval = setInterval(() => this.refresh(), 15000); // 1s tick — pure client-side; no RPC. Drives the live timer // on the active step's badge area. @@ -78,10 +103,11 @@ export class FpJobWorkspace extends Component { formatActiveStepElapsed(step) { if (!step || !step.date_started_iso) return ""; - // Parse "YYYY-MM-DD HH:MM:SS" as UTC (controller uses fp_format which - // formats in UTC by default for ISO-style strings). - const isoUtc = step.date_started_iso.replace(" ", "T") + "Z"; - const startedMs = Date.parse(isoUtc); + // Controller now sends fp_isoformat_utc — a proper ISO-8601 with + // explicit +00:00 offset. Parse directly. (Previously appended + // "Z" to a fp_format string, which had been converted to user's + // local tz, so the timer was offset by the tz delta — 4h on EDT.) + const startedMs = Date.parse(step.date_started_iso); if (!startedMs || isNaN(startedMs)) return ""; // touch state.tickNow so OWL re-evaluates this getter every tick const now = this.state.tickNow || Date.now(); @@ -97,8 +123,7 @@ export class FpJobWorkspace extends Component { // True when elapsed > 1.5× expected duration (drives red colour). // duration_expected is in minutes. if (!step || !step.date_started_iso || !step.duration_expected) return false; - const isoUtc = step.date_started_iso.replace(" ", "T") + "Z"; - const startedMs = Date.parse(isoUtc); + const startedMs = Date.parse(step.date_started_iso); if (!startedMs || isNaN(startedMs)) return false; const now = this.state.tickNow || Date.now(); const elapsedMin = (now - startedMs) / 1000 / 60; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js index 3522ef08..3b24b5e1 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js @@ -196,7 +196,12 @@ export class PlantOverview extends Component { this.state.facilityName = result.facility_name || "Plant 1"; this.state.columns = result.columns || []; this.state.racks = result.racks || []; - this.state.lastRefresh = new Date().toLocaleTimeString(); + // Prefer the server-formatted timestamp — it's in the + // FP-configured tz (fp_format → user.tz → company tz). + // Browser tz fallback only kicks in if the endpoint + // drops the field for some reason. + this.state.lastRefresh = result.server_time + || new Date().toLocaleTimeString(); } } catch (err) { this.notification.add( diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js index 57f635cb..450ee38b 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js @@ -45,10 +45,17 @@ export class FpTabletLock extends Component { loadingTiles: false, // 2026-05-24 redesign — clock + company branding // Seeded synchronously so the first render shows real values - // (no flash of empty content). - clockText: this._formatTime(new Date()), - dateText: this._formatDate(new Date()), + // (no flash of empty content). tz=null on first render falls + // back to browser tz; _loadTiles() then sets state.tz from + // res.tz_name (FP regional setting) and the next 60s tick + // recomputes with the right tz. + clockText: this._formatTime(new Date(), null), + dateText: this._formatDate(new Date(), null), company: null, + // FP-configured tz from /fp/tablet/tiles. Set after _loadTiles + // so all subsequent clock ticks render in the shop's tz, not + // the iPad's system tz. + tz: null, // Kiosk identity from bootstrap so we can tell when the // current browser session belongs to a tech (= unlocked) vs. // the kiosk (= locked). @@ -72,8 +79,8 @@ export class FpTabletLock extends Component { // 60s is enough; the displayed precision is minute-level only. this._clockInterval = setInterval(() => { const now = new Date(); - this.state.clockText = this._formatTime(now); - this.state.dateText = this._formatDate(now); + this.state.clockText = this._formatTime(now, this.state.tz); + this.state.dateText = this._formatDate(now, this.state.tz); }, 60000); // If we're already on a TECH session (uid != kiosk), start // the idle/ceiling timer immediately. This handles the case @@ -109,6 +116,13 @@ export class FpTabletLock extends Component { this.state.company = res.company || null; this.state.kioskUid = res.kiosk_uid || null; this.state.currentUid = res.current_uid || null; + this.state.tz = res.tz_name || null; + // Re-render the clock immediately with the FP tz so the + // user doesn't see browser-tz for up to 60s before the + // next tick. + const now = new Date(); + this.state.clockText = this._formatTime(now, this.state.tz); + this.state.dateText = this._formatDate(now, this.state.tz); // Decorate each tile with an animation-delay (50ms staggered, // capped at 300ms so the screen doesn't take 3s to settle on // shops with 20+ operators). @@ -188,23 +202,27 @@ export class FpTabletLock extends Component { // === 2026-05-24 redesign helpers ===================================== - _formatTime(d) { - // 12-hour H:MM AM/PM. Per project rule 20 this MUST live in JS, - // not the template — padStart isn't in OWL scope. Hour is NOT - // zero-padded (1:05 PM, not 01:05 PM) to match phone-clock idiom. - let h = d.getHours(); - const meridiem = h >= 12 ? "PM" : "AM"; - h = h % 12 || 12; // 0 → 12 - const mm = String(d.getMinutes()).padStart(2, "0"); - return h + ":" + mm + " " + meridiem; + _formatTime(d, tz) { + // 12-hour H:MM AM/PM in the FP-configured tz (falls back to + // browser tz if tz is null — first paint before _loadTiles). + // Per project rule 20 this MUST live in JS, not the template. + // Hour is NOT zero-padded (1:05 PM, not 01:05 PM) to match + // phone-clock idiom. + const opts = { hour: "numeric", minute: "2-digit", hour12: true }; + if (tz) opts.timeZone = tz; + // en-US picks 12h-with-AM/PM reliably; locale-default could vary. + return new Intl.DateTimeFormat("en-US", opts).format(d); } - _formatDate(d) { - // 'SATURDAY · MAY 23' style. Uses Intl for locale-correct weekday - // + month abbreviations, then upcases for the industrial tracking. - const weekday = d.toLocaleDateString(undefined, { weekday: "long" }); - const month = d.toLocaleDateString(undefined, { month: "short" }); - const day = d.getDate(); + _formatDate(d, tz) { + // 'SATURDAY · MAY 23' style in the FP-configured tz. + const wkOpts = { weekday: "long" }; + const moOpts = { month: "short" }; + const dyOpts = { day: "numeric" }; + if (tz) { wkOpts.timeZone = moOpts.timeZone = dyOpts.timeZone = tz; } + const weekday = new Intl.DateTimeFormat(undefined, wkOpts).format(d); + const month = new Intl.DateTimeFormat(undefined, moOpts).format(d); + const day = new Intl.DateTimeFormat(undefined, dyOpts).format(d); return (weekday + " · " + month + " " + day).toUpperCase(); }