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