Odoo · Fechas · ORM
Un informe de «ventas de hoy» que a las 00:30 todavía muestra las de ayer. Un test que falla solo cuando CI se ejecuta de madrugada. Un email que anuncia la cita una hora antes de lo que es. Todos tienen la misma causa: Odoo guarda los Datetime en UTC sin zona horaria, y el código los compara como si fueran hora local.
La regla
UTC al guardar, zona del usuario al mostrar
Un campo fields.Datetime es un datetime naive (sin tzinfo) que representa un instante en UTC. El cliente web lo convierte a la zona del usuario al pintarlo y lo devuelve a UTC al guardarlo. En el servidor, en cambio, nadie convierte nada: si tu código hace aritmética con esas fechas, está trabajando en UTC lo sepa o no.
Además, Odoo 19 fija TZ=UTC en el arranque de cualquier proceso (servidor, cron o tests), así que datetime.now() y fields.Date.today() devuelven la fecha y hora de UTC aunque el contenedor esté configurado con Europe/Madrid.
from odoo import fields
# A las 00:30 del 12 de octubre en Madrid (22:30 UTC del día 11):
fields.Date.today() # date(2026, 10, 11) ← fecha UTC
fields.Date.context_today(self) # date(2026, 10, 12) ← fecha del usuario
fields.Datetime.now() # datetime(2026, 10, 11, 22, 30) naive UTC
El caso típico
Buscar los registros de «hoy»
El error clásico es filtrar con fields.Date.today() y medianoche UTC. Lo correcto es calcular el inicio y el fin del día en la zona del usuario y convertir esos dos límites a UTC antes de buscar:
from datetime import UTC, datetime, time, timedelta
from zoneinfo import ZoneInfo
def _today_bounds_utc(self):
tz = ZoneInfo(self.env.user.tz or "UTC")
today = fields.Date.context_today(self)
start = datetime.combine(today, time.min, tzinfo=tz)
end = start + timedelta(days=1)
# Odoo compara con datetimes naive en UTC
return (
start.astimezone(UTC).replace(tzinfo=None),
end.astimezone(UTC).replace(tzinfo=None),
)
def _today_orders(self):
start, end = self._today_bounds_utc()
return self.env["sale.order"].search([
("date_order", ">=", start),
("date_order", "<", end),
])
ir.cron, normalmente OdooBot, cuya zona horaria puede no ser la de tu empresa. Si el cálculo depende de «hoy», decide explícitamente qué zona usar (la del usuario responsable del registro, la de la compañía) en vez de depender de quién ejecuta el proceso.
Mostrar
Informes, emails y mensajes del chatter
En QWeb, t-field sobre un Datetime ya convierte a la zona del contexto. El problema aparece cuando formateas tú la fecha en Python, por ejemplo para un mensaje del chatter o para Telegram. Ahí hay que pasar por context_timestamp, que devuelve un datetime consciente de la zona del usuario:
# ❌ Muestra la hora UTC: "Cita a las 07:00" cuando en Madrid son las 09:00
body = f"Cita a las {appointment.start:%H:%M}"
# ✅ Convierte a la zona del usuario del entorno
local = fields.Datetime.context_timestamp(appointment, appointment.start)
body = f"Cita a las {local:%H:%M}"
Lo mismo vale para timestamps que llegan de APIs externas en epoch: conviértelos siempre con zona explícita y quítale el tzinfo al guardar, para no depender del TZ=UTC interno de Odoo.
last_seen = datetime.fromtimestamp(payload["ts"] / 1000, tz=UTC).replace(tzinfo=None)
| Necesito… | Usa | Evita |
|---|---|---|
| El instante actual para guardar | fields.Datetime.now() | datetime.now() con zona local |
| La fecha de hoy para el usuario | fields.Date.context_today(record) | fields.Date.today() |
| Mostrar una hora en un texto | fields.Datetime.context_timestamp() | Formatear el valor UTC tal cual |
| Mostrar una hora en QWeb | t-field | t-out con el datetime crudo |
| Convertir un epoch externo | fromtimestamp(ts, tz=UTC) | fromtimestamp(ts) sin zona |
Resumen
Todo Datetime en Odoo es UTC naive y el servidor corre en UTC. «Hoy» es context_today, no today; los límites de un día se calculan en la zona del usuario y se convierten a UTC antes de buscar; y cualquier hora que formatees en Python pasa antes por context_timestamp. Los tests de estos casos, a ser posible, con una hora fija cercana a medianoche.