feat(plating): comprehensive timezone fix across dashboards/PDFs/emails
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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,5 +3,30 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from . import controllers
|
from . import controllers
|
||||||
from . import models
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.3.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -93,9 +93,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/fp_rack_views.xml',
|
'views/fp_rack_views.xml',
|
||||||
'views/fp_bath_replenishment_views.xml',
|
'views/fp_bath_replenishment_views.xml',
|
||||||
'views/fp_operator_certification_views.xml',
|
'views/fp_operator_certification_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
'views/fp_menu.xml',
|
'views/fp_menu.xml',
|
||||||
'data/fp_recipe_enp_alum_basic.xml',
|
'data/fp_recipe_enp_alum_basic.xml',
|
||||||
],
|
],
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'fusion_plating/static/src/scss/fusion_plating.scss',
|
'fusion_plating/static/src/scss/fusion_plating.scss',
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ from . import fp_bath_replenishment_rule
|
|||||||
from . import fp_process_node
|
from . import fp_process_node
|
||||||
from . import fp_rack
|
from . import fp_rack
|
||||||
from . import fp_operator_certification
|
from . import fp_operator_certification
|
||||||
|
from . import fp_tz
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
from . import res_config_settings
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
from .fp_tz import fp_isoformat_utc
|
||||||
|
|
||||||
|
|
||||||
class FpProcessNode(models.Model):
|
class FpProcessNode(models.Model):
|
||||||
"""A node in the process recipe tree.
|
"""A node in the process recipe tree.
|
||||||
@@ -312,9 +314,11 @@ class FpProcessNode(models.Model):
|
|||||||
'child_count': len(children),
|
'child_count': len(children),
|
||||||
'opt_in_out': self.opt_in_out or 'disabled',
|
'opt_in_out': self.opt_in_out or 'disabled',
|
||||||
'input_count': len(self.input_ids),
|
'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 '',
|
'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 '',
|
'write_uid_name': self.write_uid.name if self.write_uid else '',
|
||||||
'children': children,
|
'children': children,
|
||||||
}
|
}
|
||||||
|
|||||||
161
fusion_plating/fusion_plating/models/fp_tz.py
Normal file
161
fusion_plating/fusion_plating/models/fp_tz.py
Normal file
@@ -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
|
||||||
@@ -3,12 +3,32 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# 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):
|
class ResCompany(models.Model):
|
||||||
_inherit = 'res.company'
|
_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 ----------------------
|
# ----- Facility footprint for this legal entity ----------------------
|
||||||
x_fc_facility_ids = fields.One2many(
|
x_fc_facility_ids = fields.One2many(
|
||||||
'fusion.plating.facility',
|
'fusion.plating.facility',
|
||||||
|
|||||||
22
fusion_plating/fusion_plating/models/res_config_settings.py
Normal file
22
fusion_plating/fusion_plating/models/res_config_settings.py
Normal file
@@ -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',
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Fusion Plating — Settings page block.
|
||||||
|
|
||||||
|
Inherits the standard Settings form. The `app` element creates a
|
||||||
|
Fusion Plating section in the left rail; downstream modules
|
||||||
|
(certificates, invoicing, etc.) extend the same `app` with extra
|
||||||
|
blocks via xpath into //app[@name='fusion_plating'].
|
||||||
|
-->
|
||||||
|
<record id="res_config_settings_view_form_fp_core" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.fusion.plating.core</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Fusion Plating" string="Fusion Plating"
|
||||||
|
name="fusion_plating"
|
||||||
|
groups="fusion_plating.group_fusion_plating_manager">
|
||||||
|
<block title="Regional Settings"
|
||||||
|
name="fp_regional_settings"
|
||||||
|
help="Defaults applied to dashboards, reports, and emails when a user has no personal preference set.">
|
||||||
|
<setting id="fp_default_timezone"
|
||||||
|
string="Default Timezone"
|
||||||
|
help="Timezone used to display times in dashboards, PDFs, and notification emails. Detected automatically when Fusion Plating is installed; change it any time. Individual users can still override this from their own profile (Preferences > Localization).">
|
||||||
|
<field name="x_fc_default_tz"/>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.3.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -1,60 +1,63 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Extends the Fusion Plating settings app (created in core
|
||||||
|
fusion_plating/views/res_config_settings_views.xml) with the
|
||||||
|
certificate / accreditation blocks. xpath into the existing app
|
||||||
|
rather than redefining it so the two modules don't collide.
|
||||||
|
-->
|
||||||
<record id="res_config_settings_view_form_fp" model="ir.ui.view">
|
<record id="res_config_settings_view_form_fp" model="ir.ui.view">
|
||||||
<field name="name">res.config.settings.view.form.fusion.plating</field>
|
<field name="name">res.config.settings.view.form.fusion.plating.certificates</field>
|
||||||
<field name="model">res.config.settings</field>
|
<field name="model">res.config.settings</field>
|
||||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
<field name="inherit_id" ref="fusion_plating.res_config_settings_view_form_fp_core"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//form" position="inside">
|
<xpath expr="//app[@name='fusion_plating']" position="inside">
|
||||||
<app data-string="Fusion Plating" string="Fusion Plating"
|
<block title="Certificate of Conformance"
|
||||||
name="fusion_plating" groups="fusion_plating.group_fusion_plating_manager">
|
name="fp_coc_settings"
|
||||||
<block title="Certificate of Conformance"
|
help="Branding, accreditation logos, and default signer for Certificates of Conformance.">
|
||||||
name="fp_coc_settings"
|
<setting id="fp_coc_owner"
|
||||||
help="Branding, accreditation logos, and default signer for Certificates of Conformance.">
|
string="Certificate Owner (Default Signer)"
|
||||||
<setting id="fp_coc_owner"
|
help="Their HR Employee signature appears on issued certificates by default.">
|
||||||
string="Certificate Owner (Default Signer)"
|
<field name="x_fc_owner_user_id"
|
||||||
help="Their HR Employee signature appears on issued certificates by default.">
|
options="{'no_create': True, 'no_open': True}"/>
|
||||||
<field name="x_fc_owner_user_id"
|
</setting>
|
||||||
options="{'no_create': True, 'no_open': True}"/>
|
<setting id="fp_coc_sig_override"
|
||||||
</setting>
|
string="Signature Override Image"
|
||||||
<setting id="fp_coc_sig_override"
|
help="Upload a scanned signature here to override the owner user's employee signature (useful if they don't have an HR record).">
|
||||||
string="Signature Override Image"
|
<field name="x_fc_coc_signature_override"
|
||||||
help="Upload a scanned signature here to override the owner user's employee signature (useful if they don't have an HR record).">
|
widget="image" class="oe_avatar"/>
|
||||||
<field name="x_fc_coc_signature_override"
|
</setting>
|
||||||
widget="image" class="oe_avatar"/>
|
</block>
|
||||||
</setting>
|
|
||||||
</block>
|
|
||||||
|
|
||||||
<block title="Accreditation Logos"
|
<block title="Accreditation Logos"
|
||||||
name="fp_accreditation_logos"
|
name="fp_accreditation_logos"
|
||||||
help="Upload the logos and toggle each on to display it in the CoC header. Sized automatically in the PDF.">
|
help="Upload the logos and toggle each on to display it in the CoC header. Sized automatically in the PDF.">
|
||||||
<setting id="fp_nadcap"
|
<setting id="fp_nadcap"
|
||||||
string="Nadcap Accredited"
|
string="Nadcap Accredited"
|
||||||
help="Administered by PRI. Upload the official Nadcap Accredited logo.">
|
help="Administered by PRI. Upload the official Nadcap Accredited logo.">
|
||||||
<field name="x_fc_nadcap_active"/>
|
<field name="x_fc_nadcap_active"/>
|
||||||
<field name="x_fc_nadcap_logo"
|
<field name="x_fc_nadcap_logo"
|
||||||
widget="image" class="oe_avatar"
|
widget="image" class="oe_avatar"
|
||||||
invisible="not x_fc_nadcap_active"/>
|
invisible="not x_fc_nadcap_active"/>
|
||||||
</setting>
|
</setting>
|
||||||
<setting id="fp_as9100"
|
<setting id="fp_as9100"
|
||||||
string="AS9100 / ISO 9001"
|
string="AS9100 / ISO 9001"
|
||||||
help="AS9100D / ISO 9001 certified. Upload the combined logo.">
|
help="AS9100D / ISO 9001 certified. Upload the combined logo.">
|
||||||
<field name="x_fc_as9100_active"/>
|
<field name="x_fc_as9100_active"/>
|
||||||
<field name="x_fc_as9100_logo"
|
<field name="x_fc_as9100_logo"
|
||||||
widget="image" class="oe_avatar"
|
widget="image" class="oe_avatar"
|
||||||
invisible="not x_fc_as9100_active"/>
|
invisible="not x_fc_as9100_active"/>
|
||||||
</setting>
|
</setting>
|
||||||
<setting id="fp_cgp"
|
<setting id="fp_cgp"
|
||||||
string="Controlled Goods Program (CGP)"
|
string="Controlled Goods Program (CGP)"
|
||||||
help="Registered with Canada's Controlled Goods Program.">
|
help="Registered with Canada's Controlled Goods Program.">
|
||||||
<field name="x_fc_cgp_active"/>
|
<field name="x_fc_cgp_active"/>
|
||||||
<field name="x_fc_cgp_logo"
|
<field name="x_fc_cgp_logo"
|
||||||
widget="image" class="oe_avatar"
|
widget="image" class="oe_avatar"
|
||||||
invisible="not x_fc_cgp_active"/>
|
invisible="not x_fc_cgp_active"/>
|
||||||
</setting>
|
</setting>
|
||||||
</block>
|
</block>
|
||||||
</app>
|
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Notifications',
|
'name': 'Fusion Plating — Notifications',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.3.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||||
<td style="padding: 8px 4px;">Order Date</td>
|
<td style="padding: 8px 4px;">Order Date</td>
|
||||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.date_order.strftime('%b %d, %Y') if object.date_order else '—'"/></td>
|
<td style="padding: 8px 4px; text-align: right;"><t t-out="format_datetime(object.date_order, tz=(user.tz or object.company_id.x_fc_default_tz or 'UTC'), dt_format='MMM d, y') if object.date_order else '—'"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||||
<td style="padding: 8px 4px;"><strong>Total</strong></td>
|
<td style="padding: 8px 4px;"><strong>Total</strong></td>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||||
<td style="padding: 8px 4px;">Delivered</td>
|
<td style="padding: 8px 4px;">Delivered</td>
|
||||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.delivered_at.strftime('%b %d, %Y %H:%M') if object.delivered_at else '—'"/></td>
|
<td style="padding: 8px 4px; text-align: right;"><t t-out="format_datetime(object.delivered_at, tz=(user.tz or object.company_id.x_fc_default_tz or 'UTC'), dt_format='MMM d, y HH:mm') if object.delivered_at else '—'"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||||
<td style="padding: 8px 4px;">Driver</td>
|
<td style="padding: 8px 4px;">Driver</td>
|
||||||
@@ -318,11 +318,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||||
<td style="padding: 8px 4px;">Invoice Date</td>
|
<td style="padding: 8px 4px;">Invoice Date</td>
|
||||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.invoice_date.strftime('%b %d, %Y') if object.invoice_date else '—'"/></td>
|
<td style="padding: 8px 4px; text-align: right;"><t t-out="format_date(object.invoice_date, date_format='MMM d, y') if object.invoice_date else '—'"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||||
<td style="padding: 8px 4px;">Due Date</td>
|
<td style="padding: 8px 4px;">Due Date</td>
|
||||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.invoice_date_due.strftime('%b %d, %Y') if object.invoice_date_due else '—'"/></td>
|
<td style="padding: 8px 4px; text-align: right;"><t t-out="format_date(object.invoice_date_due, date_format='MMM d, y') if object.invoice_date_due else '—'"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||||
<td style="padding: 8px 4px;"><strong>Amount Due</strong></td>
|
<td style="padding: 8px 4px;"><strong>Amount Due</strong></td>
|
||||||
@@ -379,7 +379,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||||
<td style="padding: 8px 4px;">Payment Date</td>
|
<td style="padding: 8px 4px;">Payment Date</td>
|
||||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.date.strftime('%b %d, %Y') if object.date else '—'"/></td>
|
<td style="padding: 8px 4px; text-align: right;"><t t-out="format_date(object.date, date_format='MMM d, y') if object.date else '—'"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||||
<td style="padding: 8px 4px;"><strong>Amount</strong></td>
|
<td style="padding: 8px 4px;"><strong>Amount</strong></td>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.3.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
<td class="text-center"><span t-field="rec.name"/></td>
|
<td class="text-center"><span t-field="rec.name"/></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<t t-if="rec.received_date">
|
<t t-if="rec.received_date">
|
||||||
<span t-field="rec.received_date"/>
|
<span t-field="rec.received_date" t-options="{'widget': 'datetime'}"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">—</t>
|
<t t-else="">—</t>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.8.0.0',
|
'version': '19.0.9.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'first-piece inspection gates.',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from odoo import http
|
from odoo import http
|
||||||
|
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -89,7 +90,7 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
'customer': partner.name if partner else '',
|
'customer': partner.name if partner else '',
|
||||||
'product': mo.product_id.display_name if mo.product_id else '',
|
'product': mo.product_id.display_name if mo.product_id else '',
|
||||||
'qty_total': int(mo.product_qty or 0),
|
'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 '',
|
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
|
||||||
'priority_any': max(
|
'priority_any': max(
|
||||||
[int(w.x_fc_priority or '0') for w in wos] + [0]
|
[int(w.x_fc_priority or '0') for w in wos] + [0]
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from odoo import fields, http
|
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.exceptions import UserError
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
@@ -103,7 +107,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'name': bw.name,
|
'name': bw.name,
|
||||||
'state': bw.state,
|
'state': bw.state,
|
||||||
'time_remaining': bw.time_remaining_display,
|
'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:'):
|
if code.startswith('FP-OVEN:'):
|
||||||
@@ -194,7 +198,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'state': bw.state,
|
'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')
|
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
|
||||||
@@ -206,7 +210,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'state': bw.state,
|
'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,
|
'bake_duration_hours': bw.bake_duration_hours,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +425,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
role.name if role else '',
|
role.name if role else '',
|
||||||
])),
|
])),
|
||||||
'priority': 90 if wo.state == 'ready' else 60,
|
'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_model': 'mrp.workorder',
|
||||||
'source_id': wo.id,
|
'source_id': wo.id,
|
||||||
'wo_state': wo.state,
|
'wo_state': wo.state,
|
||||||
@@ -439,7 +443,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'label': r.label,
|
'label': r.label,
|
||||||
'description': r.description,
|
'description': r.description,
|
||||||
'priority': r.priority,
|
'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_model': r.source_model,
|
||||||
'source_id': r.source_id,
|
'source_id': r.source_id,
|
||||||
'wo_state': '',
|
'wo_state': '',
|
||||||
@@ -477,7 +481,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'name': b.display_name or b.name,
|
'name': b.display_name or b.name,
|
||||||
'tank': b.tank_id.name or '',
|
'tank': b.tank_id.name or '',
|
||||||
'state': b.state 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 '',
|
'last_log_status': b.last_log_status or '',
|
||||||
'mto': round(b.mto_count or 0, 2),
|
'mto': round(b.mto_count or 0, 2),
|
||||||
}
|
}
|
||||||
@@ -496,7 +500,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'customer': bw.customer_ref or '',
|
'customer': bw.customer_ref or '',
|
||||||
'state': bw.state,
|
'state': bw.state,
|
||||||
'remaining': bw.time_remaining_display or '',
|
'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,
|
'quantity': bw.quantity or 0,
|
||||||
}
|
}
|
||||||
for bw in bws
|
for bw in bws
|
||||||
@@ -513,7 +517,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'customer': g.customer_ref or '',
|
'customer': g.customer_ref or '',
|
||||||
'bath': g.bath_id.name or '',
|
'bath': g.bath_id.name or '',
|
||||||
'result': g.result,
|
'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 '',
|
'inspector': g.inspector_id.name or '',
|
||||||
}
|
}
|
||||||
for g in gates
|
for g in gates
|
||||||
@@ -571,7 +575,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
'gates': gates_data,
|
'gates': gates_data,
|
||||||
'holds': holds_data,
|
'holds': holds_data,
|
||||||
'stations': stations,
|
'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,
|
'label': r.label,
|
||||||
'description': r.description,
|
'description': r.description,
|
||||||
'priority': r.priority,
|
'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_model': r.source_model,
|
||||||
'source_id': r.source_id,
|
'source_id': r.source_id,
|
||||||
}
|
}
|
||||||
@@ -788,7 +792,7 @@ class FpShopfloorController(http.Controller):
|
|||||||
last_log = wo.time_ids.sorted('date_start', reverse=True)[:1]
|
last_log = wo.time_ids.sorted('date_start', reverse=True)[:1]
|
||||||
if last_log and last_log.user_id:
|
if last_log and last_log.user_id:
|
||||||
last_operator = last_log.user_id.name or ''
|
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 from priority field
|
||||||
tags = []
|
tags = []
|
||||||
@@ -798,12 +802,16 @@ class FpShopfloorController(http.Controller):
|
|||||||
elif prio == '1':
|
elif prio == '1':
|
||||||
tags.append('Priority')
|
tags.append('Priority')
|
||||||
|
|
||||||
# Date display
|
# Date display — formatted in the user's timezone (DB is UTC, but
|
||||||
date_display = ''
|
# the operator wants to see "today" in their local time).
|
||||||
if wo.date_start:
|
date_display = (
|
||||||
date_display = wo.date_start.strftime('%-m/%-d')
|
fp_format(request.env, wo.date_start, fmt='%-m/%-d')
|
||||||
elif production and production.date_start:
|
or fp_format(
|
||||||
date_display = production.date_start.strftime('%-m/%-d')
|
request.env,
|
||||||
|
production.date_start if production else False,
|
||||||
|
fmt='%-m/%-d',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Step info
|
# Step info
|
||||||
step_display = getattr(wo, 'x_fc_step_display', '') or ''
|
step_display = getattr(wo, 'x_fc_step_display', '') or ''
|
||||||
@@ -860,9 +868,9 @@ class FpShopfloorController(http.Controller):
|
|||||||
'parts_done': 0,
|
'parts_done': 0,
|
||||||
'parts_total': 0,
|
'parts_total': 0,
|
||||||
'last_operator': '',
|
'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 [],
|
'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 '',
|
'state': bw.state or '',
|
||||||
}
|
}
|
||||||
if not search or self._card_matches_search(card, search):
|
if not search or self._card_matches_search(card, search):
|
||||||
@@ -892,9 +900,9 @@ class FpShopfloorController(http.Controller):
|
|||||||
'parts_done': 0,
|
'parts_done': 0,
|
||||||
'parts_total': 0,
|
'parts_total': 0,
|
||||||
'last_operator': gate.inspector_id.name if gate.inspector_id else '',
|
'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': [],
|
'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 '',
|
'state': gate.result or '',
|
||||||
}
|
}
|
||||||
if not search or self._card_matches_search(card, search):
|
if not search or self._card_matches_search(card, search):
|
||||||
@@ -918,34 +926,6 @@ class FpShopfloorController(http.Controller):
|
|||||||
]).lower()
|
]).lower()
|
||||||
return search in searchable
|
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
|
# Work Order — Process Flow + Cost Summary
|
||||||
# ==================================================================
|
# ==================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user