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
# ==================================================================