From 6c4ff7751f68d5dbb885948f8daf1bb00dd8db11 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 21:03:02 -0400 Subject: [PATCH] feat(plating): comprehensive timezone fix across dashboards/PDFs/emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Database stores datetimes naive-UTC, but the dashboards and emails were showing UTC strings to users in EST/EDT — making 9pm Toronto look like 1am the next day. Adds a single helper module + auto-detection on install. Core changes (fusion_plating): - New fp_tz.py helper: fp_user_tz, fp_format, fp_isoformat_utc, fp_time_ago Resolves user.tz → company.x_fc_default_tz → UTC. - res.company.x_fc_default_tz Selection (full pytz IANA list) - res.config.settings exposes the company tz under a new "Regional Settings" block in Settings > Fusion Plating - post_init_hook auto-populates the tz on first install: tries admin user → server /etc/timezone → America/Toronto fallback - fp_process_node._to_dict now sends create_date/write_date as ISO with explicit +00:00 marker so JS new Date() parses it as UTC and the recipe tree editor's "time ago" math works correctly Shop-floor controllers: - shopfloor_controller.py: every fields.Datetime.to_string() and naive .strftime() swapped for fp_format(env, ...) — due_at, bake times, last_log_date, gates, server_time all now in user's tz - _time_ago() removed; replaced with fp_time_ago helper which compares tz-aware datetimes (the local one was naive-vs-naive and could be off by hours) - manager_controller.py date_planned: str(...)[:10] slice replaced with fp_format MM/DD in user's tz Notifications + reports: - mail_template_data.xml: 5 .strftime() calls in body_html → babel format_datetime / format_date with tz=(user.tz or company tz) - report_fp_job_traveller.xml: rec.received_date (Datetime) gets t-options="{'widget':'datetime'}" so Odoo's QWeb renders in user tz Settings view layout: - fusion_plating now owns the Settings page "Fusion Plating" app shell - fusion_plating_certificates xpaths into it instead of redefining (prevents app-name collision) Verified on odoo-entech (LXC 111): post_init_hook detects America/Toronto from /etc/timezone, MO date_start 2026-04-17 05:28 UTC correctly displays as 2026-04-17 01:28 EDT. Module versions bumped: fusion_plating 19.0.3.0.0, fusion_plating_shopfloor 19.0.9.0.0, plus certificates / notifications / reports → 19.0.3.0.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__init__.py | 25 +++ fusion_plating/fusion_plating/__manifest__.py | 4 +- .../fusion_plating/models/__init__.py | 2 + .../fusion_plating/models/fp_process_node.py | 8 +- fusion_plating/fusion_plating/models/fp_tz.py | 161 ++++++++++++++++++ .../fusion_plating/models/res_company.py | 22 ++- .../models/res_config_settings.py | 22 +++ .../views/res_config_settings_views.xml | 35 ++++ .../__manifest__.py | 2 +- .../views/res_config_settings_views.xml | 103 +++++------ .../__manifest__.py | 2 +- .../data/mail_template_data.xml | 10 +- .../fusion_plating_reports/__manifest__.py | 2 +- .../report/report_fp_job_traveller.xml | 2 +- .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/manager_controller.py | 3 +- .../controllers/shopfloor_controller.py | 80 ++++----- 17 files changed, 370 insertions(+), 115 deletions(-) create mode 100644 fusion_plating/fusion_plating/models/fp_tz.py create mode 100644 fusion_plating/fusion_plating/models/res_config_settings.py create mode 100644 fusion_plating/fusion_plating/views/res_config_settings_views.xml diff --git a/fusion_plating/fusion_plating/__init__.py b/fusion_plating/fusion_plating/__init__.py index 2ea9535e..b237c4b8 100644 --- a/fusion_plating/fusion_plating/__init__.py +++ b/fusion_plating/fusion_plating/__init__.py @@ -3,5 +3,30 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +import logging + from . import controllers from . import models + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Auto-detect a sensible default timezone on first install. + + Sets ``res.company.x_fc_default_tz`` to the admin user's timezone + (Odoo populates that from the browser on first login), falling back + to the host server's timezone, then to ``America/Toronto`` as a + last resort. Only writes when the field is still empty so re-installs + never clobber a user's choice. + """ + from .models.fp_tz import detect_default_tz + + detected = detect_default_tz(env) + for company in env['res.company'].sudo().search([]): + if not company.x_fc_default_tz: + company.x_fc_default_tz = detected + _logger.info( + 'Fusion Plating: set default timezone for company %s -> %s', + company.name, detected, + ) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 9505ba36..cda664c7 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.2.0.0', + 'version': '19.0.3.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -93,9 +93,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_rack_views.xml', 'views/fp_bath_replenishment_views.xml', 'views/fp_operator_certification_views.xml', + 'views/res_config_settings_views.xml', 'views/fp_menu.xml', 'data/fp_recipe_enp_alum_basic.xml', ], + 'post_init_hook': 'post_init_hook', 'assets': { 'web.assets_backend': [ 'fusion_plating/static/src/scss/fusion_plating.scss', diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 9c96365d..a8df88f7 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -16,4 +16,6 @@ from . import fp_bath_replenishment_rule from . import fp_process_node from . import fp_rack from . import fp_operator_certification +from . import fp_tz from . import res_company +from . import res_config_settings diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index b893de95..f2799322 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -6,6 +6,8 @@ from odoo import api, fields, models, _ from odoo.exceptions import ValidationError +from .fp_tz import fp_isoformat_utc + class FpProcessNode(models.Model): """A node in the process recipe tree. @@ -312,9 +314,11 @@ class FpProcessNode(models.Model): 'child_count': len(children), 'opt_in_out': self.opt_in_out or 'disabled', 'input_count': len(self.input_ids), - 'create_date': self.create_date.isoformat() if self.create_date else '', + # ISO with explicit UTC marker so JS new Date() parses it + # correctly and re-localises to the browser's timezone. + 'create_date': fp_isoformat_utc(self.create_date), 'create_uid_name': self.create_uid.name if self.create_uid else '', - 'write_date': self.write_date.isoformat() if self.write_date else '', + 'write_date': fp_isoformat_utc(self.write_date), 'write_uid_name': self.write_uid.name if self.write_uid else '', 'children': children, } diff --git a/fusion_plating/fusion_plating/models/fp_tz.py b/fusion_plating/fusion_plating/models/fp_tz.py new file mode 100644 index 00000000..b9e1e4c8 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_tz.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""Timezone helpers for Fusion Plating. + +The Postgres database stores all datetimes naive-UTC. Anything that is +shown to a user — dashboards, PDFs, emails, OWL frontends — must be +converted to a human's local timezone first. + +Resolution order for "what timezone does this user see": + 1. The current user's `res.users.tz` + 2. The current company's `x_fc_default_tz` (Fusion Plating setting) + 3. UTC + +Use ``fp_user_tz(env)`` to get the resolved pytz tzinfo, then either +convert datetimes yourself or use the convenience helpers +``fp_format`` / ``fp_isoformat_utc``. +""" + +import pytz + + +def fp_user_tz(env): + """Return a pytz tzinfo for the current user (or company fallback).""" + name = ( + (env.user.tz if env and env.user else None) + or (env.company.x_fc_default_tz if env and env.company else None) + or 'UTC' + ) + try: + return pytz.timezone(name) + except Exception: + return pytz.UTC + + +def fp_to_user_tz(env, dt): + """Convert a naive UTC datetime to a tz-aware datetime in the user's tz. + + Returns ``None`` if ``dt`` is falsy. Datetimes that already carry a + tzinfo are converted in place; naive ones are assumed to be UTC + (matching Odoo's storage convention). + """ + if not dt: + return None + tz = fp_user_tz(env) + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + return dt.astimezone(tz) + + +def fp_format(env, dt, fmt='%Y-%m-%d %H:%M'): + """Format a naive UTC datetime as a string in the user's tz. + + Returns an empty string when ``dt`` is falsy so callers can use the + result directly in dicts / templates without ``if`` guards. + """ + if not dt: + return '' + return fp_to_user_tz(env, dt).strftime(fmt) + + +def fp_isoformat_utc(dt): + """Return an ISO-8601 string with an explicit UTC marker. + + Naive datetimes from Odoo are assumed UTC. Adding the ``+00:00`` + suffix tells JavaScript ``new Date(...)`` to parse the string as + UTC (without it, the browser interprets the string as *local* + wall time and silently shifts it). Pair this with frontend code + that calls ``.toLocaleString()`` on the resulting Date object so + the user sees their own local time. + """ + if not dt: + return '' + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + return dt.isoformat() + + +def fp_time_ago(env, dt): + """Return a 'just now / 5m ago / 2h ago / 3d ago' string. + + Both sides of the comparison are converted to UTC tz-aware first so + that the delta is meaningful regardless of where Odoo or the user + happens to be running. + """ + if not dt: + return '' + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + from datetime import datetime + now = datetime.now(pytz.UTC) + delta = now - dt + total_seconds = int(delta.total_seconds()) + if total_seconds < 0: + total_seconds = 0 + if total_seconds < 60: + return 'just now' + minutes = total_seconds // 60 + if minutes < 60: + return f'{minutes}m ago' + hours = minutes // 60 + if hours < 24: + return f'{hours}h ago' + days = hours // 24 + if days < 7: + return f'{days}d ago' + weeks = days // 7 + remaining_days = days % 7 + if remaining_days: + return f'{weeks}w {remaining_days}d ago' + return f'{weeks}w ago' + + +# --------------------------------------------------------------------------- +# Auto-detection used by the post_init_hook +# --------------------------------------------------------------------------- + +_FALLBACK_TZ = 'America/Toronto' + + +def detect_default_tz(env=None): + """Best guess at a sensible default tz when the module is installed. + + Tries, in order: + 1. The admin user's ``tz`` (Odoo sets it from the browser on login). + 2. The current company's ``partner_id.tz``. + 3. The host server's IANA timezone (Linux typical). + 4. ``America/Toronto`` as the final fallback (this project is + Canada-focused and that's the most likely correct guess). + """ + if env is not None: + admin = env.ref('base.user_admin', raise_if_not_found=False) + if admin and admin.tz: + return admin.tz + try: + partner_tz = env.company.partner_id.tz + except Exception: + partner_tz = None + if partner_tz: + return partner_tz + + # Server-side detection — works on most Linux hosts. + try: + from datetime import datetime + local = datetime.now().astimezone() + name = str(local.tzinfo) + if name and name in pytz.all_timezones: + return name + except Exception: + pass + + try: + with open('/etc/timezone', 'r') as fh: + tz = fh.read().strip() + if tz in pytz.all_timezones: + return tz + except Exception: + pass + + return _FALLBACK_TZ diff --git a/fusion_plating/fusion_plating/models/res_company.py b/fusion_plating/fusion_plating/models/res_company.py index b1ac517c..45965ac4 100644 --- a/fusion_plating/fusion_plating/models/res_company.py +++ b/fusion_plating/fusion_plating/models/res_company.py @@ -3,12 +3,32 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import fields, models +from odoo import api, fields, models + + +def _fp_tz_get(self): + """Same selection list Odoo uses on res.partner.tz.""" + import pytz + return [(tz, tz) for tz in pytz.all_timezones] class ResCompany(models.Model): _inherit = 'res.company' + # ----- Fusion Plating default timezone -------------------------------- + # Used as the fallback whenever a user's res.users.tz is empty (cron + # jobs, batch emails, headless contexts). Auto-populated by the + # post_init_hook in fusion_plating/__init__.py. + x_fc_default_tz = fields.Selection( + _fp_tz_get, + string='Fusion Plating Timezone', + default=lambda self: self.env.user.tz or 'America/Toronto', + help='Timezone used for plating dashboards, reports, and emails when ' + 'a user has no personal timezone set. Detected automatically on ' + 'install; the admin can change it any time from ' + 'Settings > Fusion Plating.', + ) + # ----- Facility footprint for this legal entity ---------------------- x_fc_facility_ids = fields.One2many( 'fusion.plating.facility', diff --git a/fusion_plating/fusion_plating/models/res_config_settings.py b/fusion_plating/fusion_plating/models/res_config_settings.py new file mode 100644 index 00000000..79de409a --- /dev/null +++ b/fusion_plating/fusion_plating/models/res_config_settings.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + """Expose Fusion Plating company-level settings on the Settings page. + + Today this only carries the default timezone, but it's the single + place to add new shop-wide preferences (default facility, currency + overrides, etc.). + """ + _inherit = 'res.config.settings' + + x_fc_default_tz = fields.Selection( + related='company_id.x_fc_default_tz', + readonly=False, + string='Fusion Plating Timezone', + ) diff --git a/fusion_plating/fusion_plating/views/res_config_settings_views.xml b/fusion_plating/fusion_plating/views/res_config_settings_views.xml new file mode 100644 index 00000000..99b812bd --- /dev/null +++ b/fusion_plating/fusion_plating/views/res_config_settings_views.xml @@ -0,0 +1,35 @@ + + + + + + res.config.settings.view.form.fusion.plating.core + res.config.settings + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index ed33a40f..fadb8948 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Certificates', - 'version': '19.0.2.0.0', + 'version': '19.0.3.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'description': """ diff --git a/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml b/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml index 3d4f6c8a..f4d4a6f4 100644 --- a/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml +++ b/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml @@ -1,60 +1,63 @@ + - res.config.settings.view.form.fusion.plating + res.config.settings.view.form.fusion.plating.certificates res.config.settings - + - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_notifications/__manifest__.py b/fusion_plating/fusion_plating_notifications/__manifest__.py index d711c6cb..d1264a02 100644 --- a/fusion_plating/fusion_plating_notifications/__manifest__.py +++ b/fusion_plating/fusion_plating_notifications/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Notifications', - 'version': '19.0.2.0.0', + 'version': '19.0.3.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml b/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml index fe7178d3..cd9235c0 100644 --- a/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml +++ b/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml @@ -98,7 +98,7 @@ Order Date - + Total @@ -261,7 +261,7 @@ Delivered - + Driver @@ -318,11 +318,11 @@ Invoice Date - + Due Date - + Amount Due @@ -379,7 +379,7 @@ Payment Date - + Amount diff --git a/fusion_plating/fusion_plating_reports/__manifest__.py b/fusion_plating/fusion_plating_reports/__manifest__.py index b76343dc..7fb1e2df 100644 --- a/fusion_plating/fusion_plating_reports/__manifest__.py +++ b/fusion_plating/fusion_plating_reports/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Reports', - 'version': '19.0.2.0.0', + 'version': '19.0.3.0.0', 'category': 'Manufacturing/Plating', 'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.', 'depends': [ diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml b/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml index ea88aae7..4876ba13 100644 --- a/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml +++ b/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml @@ -157,7 +157,7 @@ - + diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index aa9aab1e..5a15b971 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.8.0.0', + 'version': '19.0.9.0.0', '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/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index c132b6ae..d3243fd8 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -6,6 +6,7 @@ import logging from odoo import http +from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.http import request _logger = logging.getLogger(__name__) @@ -89,7 +90,7 @@ class FpManagerDashboardController(http.Controller): 'customer': partner.name if partner else '', 'product': mo.product_id.display_name if mo.product_id else '', 'qty_total': int(mo.product_qty or 0), - 'date_planned': str(mo.date_start)[:10] if mo.date_start else '', + 'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'), 'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '', 'priority_any': max( [int(w.x_fc_priority or '0') for w in wos] + [0] diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py index 8e6450b9..172498da 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py @@ -4,9 +4,13 @@ # Part of the Fusion Plating product family. import logging -from datetime import datetime from odoo import fields, http +from odoo.addons.fusion_plating.models.fp_tz import ( + fp_format, + fp_isoformat_utc, + fp_time_ago, +) from odoo.exceptions import UserError from odoo.http import request @@ -103,7 +107,7 @@ class FpShopfloorController(http.Controller): 'name': bw.name, 'state': bw.state, 'time_remaining': bw.time_remaining_display, - 'bake_required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '', + 'bake_required_by': fp_format(request.env, bw.bake_required_by), } if code.startswith('FP-OVEN:'): @@ -194,7 +198,7 @@ class FpShopfloorController(http.Controller): return { 'ok': True, 'state': bw.state, - 'bake_start_time': fields.Datetime.to_string(bw.bake_start_time) if bw.bake_start_time else '', + 'bake_start_time': fp_format(request.env, bw.bake_start_time), } @http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user') @@ -206,7 +210,7 @@ class FpShopfloorController(http.Controller): return { 'ok': True, 'state': bw.state, - 'bake_end_time': fields.Datetime.to_string(bw.bake_end_time) if bw.bake_end_time else '', + 'bake_end_time': fp_format(request.env, bw.bake_end_time), 'bake_duration_hours': bw.bake_duration_hours, } @@ -421,7 +425,7 @@ class FpShopfloorController(http.Controller): role.name if role else '', ])), 'priority': 90 if wo.state == 'ready' else 60, - 'due_at': fields.Datetime.to_string(wo.date_start) if wo.date_start else '', + 'due_at': fp_format(request.env, wo.date_start), 'source_model': 'mrp.workorder', 'source_id': wo.id, 'wo_state': wo.state, @@ -439,7 +443,7 @@ class FpShopfloorController(http.Controller): 'label': r.label, 'description': r.description, 'priority': r.priority, - 'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '', + 'due_at': fp_format(request.env, r.due_at), 'source_model': r.source_model, 'source_id': r.source_id, 'wo_state': '', @@ -477,7 +481,7 @@ class FpShopfloorController(http.Controller): 'name': b.display_name or b.name, 'tank': b.tank_id.name or '', 'state': b.state or '', - 'last_log_date': fields.Datetime.to_string(b.last_log_date) if b.last_log_date else '', + 'last_log_date': fp_format(request.env, b.last_log_date), 'last_log_status': b.last_log_status or '', 'mto': round(b.mto_count or 0, 2), } @@ -496,7 +500,7 @@ class FpShopfloorController(http.Controller): 'customer': bw.customer_ref or '', 'state': bw.state, 'remaining': bw.time_remaining_display or '', - 'required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '', + 'required_by': fp_format(request.env, bw.bake_required_by), 'quantity': bw.quantity or 0, } for bw in bws @@ -513,7 +517,7 @@ class FpShopfloorController(http.Controller): 'customer': g.customer_ref or '', 'bath': g.bath_id.name or '', 'result': g.result, - 'first_piece': fields.Datetime.to_string(g.first_piece_produced) if g.first_piece_produced else '', + 'first_piece': fp_format(request.env, g.first_piece_produced), 'inspector': g.inspector_id.name or '', } for g in gates @@ -571,7 +575,7 @@ class FpShopfloorController(http.Controller): 'gates': gates_data, 'holds': holds_data, 'stations': stations, - 'server_time': fields.Datetime.to_string(fields.Datetime.now()), + 'server_time': fp_format(request.env, fields.Datetime.now(), fmt='%Y-%m-%d %H:%M:%S'), } # ---------------------------------------------------------------------- @@ -623,7 +627,7 @@ class FpShopfloorController(http.Controller): 'label': r.label, 'description': r.description, 'priority': r.priority, - 'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '', + 'due_at': fp_format(request.env, r.due_at), 'source_model': r.source_model, 'source_id': r.source_id, } @@ -788,7 +792,7 @@ class FpShopfloorController(http.Controller): last_log = wo.time_ids.sorted('date_start', reverse=True)[:1] if last_log and last_log.user_id: last_operator = last_log.user_id.name or '' - last_activity = self._time_ago(wo.write_date or wo.date_start) + last_activity = fp_time_ago(request.env, wo.write_date or wo.date_start) # Tags from priority field tags = [] @@ -798,12 +802,16 @@ class FpShopfloorController(http.Controller): elif prio == '1': tags.append('Priority') - # Date display - date_display = '' - if wo.date_start: - date_display = wo.date_start.strftime('%-m/%-d') - elif production and production.date_start: - date_display = production.date_start.strftime('%-m/%-d') + # Date display — formatted in the user's timezone (DB is UTC, but + # the operator wants to see "today" in their local time). + date_display = ( + fp_format(request.env, wo.date_start, fmt='%-m/%-d') + or fp_format( + request.env, + production.date_start if production else False, + fmt='%-m/%-d', + ) + ) # Step info step_display = getattr(wo, 'x_fc_step_display', '') or '' @@ -860,9 +868,9 @@ class FpShopfloorController(http.Controller): 'parts_done': 0, 'parts_total': 0, 'last_operator': '', - 'last_activity': self._time_ago(bw.write_date) if bw.write_date else '', + 'last_activity': fp_time_ago(request.env, bw.write_date), 'tags': ['HOT'] if bw.state == 'missed_window' else [], - 'date_display': bw.bake_required_by.strftime('%-m/%-d') if bw.bake_required_by else '', + 'date_display': fp_format(request.env, bw.bake_required_by, fmt='%-m/%-d'), 'state': bw.state or '', } if not search or self._card_matches_search(card, search): @@ -892,9 +900,9 @@ class FpShopfloorController(http.Controller): 'parts_done': 0, 'parts_total': 0, 'last_operator': gate.inspector_id.name if gate.inspector_id else '', - 'last_activity': self._time_ago(gate.write_date) if gate.write_date else '', + 'last_activity': fp_time_ago(request.env, gate.write_date), 'tags': [], - 'date_display': gate.create_date.strftime('%-m/%-d') if gate.create_date else '', + 'date_display': fp_format(request.env, gate.create_date, fmt='%-m/%-d'), 'state': gate.result or '', } if not search or self._card_matches_search(card, search): @@ -918,34 +926,6 @@ class FpShopfloorController(http.Controller): ]).lower() return search in searchable - def _time_ago(self, dt): - """Return a human-readable 'time ago' string from a datetime.""" - if not dt: - return '' - now = datetime.now() - if hasattr(dt, 'replace'): - # Make both naive for comparison - dt = dt.replace(tzinfo=None) - delta = now - dt - total_seconds = int(delta.total_seconds()) - - if total_seconds < 60: - return 'just now' - minutes = total_seconds // 60 - if minutes < 60: - return f'{minutes}m ago' - hours = minutes // 60 - if hours < 24: - return f'{hours}h ago' - days = hours // 24 - if days < 7: - return f'{days}d ago' - weeks = days // 7 - remaining_days = days % 7 - if remaining_days: - return f'{weeks}w {remaining_days}d ago' - return f'{weeks}w ago' - # ================================================================== # Work Order — Process Flow + Cost Summary # ==================================================================