fix(tablet): ACL, loading hang, timer offset + FP-tz clock

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 20:31:25 -04:00
parent bfc138251a
commit 80f80fb707
8 changed files with 102 additions and 37 deletions

View File

@@ -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.',

View File

@@ -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

View File

@@ -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.',

View File

@@ -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)),
}
# ======================================================================

View File

@@ -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 '',

View File

@@ -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;

View File

@@ -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(

View File

@@ -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();
}