fix: fix timezone handling across backend and frontend (v0.3.1)

- Add tz.py with local_strftime/to_local helpers that read system.timezone
  from DB and convert UTC datetimes to venue local time before formatting
- Fix all strftime() calls in orders.py, reports.py, printer_service.py
  that were formatting UTC datetimes without timezone conversion
- Fix get_order endpoint returning raw dicts without Z suffix on datetimes,
  causing JS new Date() to treat timestamps as local instead of UTC
- Fix fmtDate() in tokens.js that stripped the T separator before parsing,
  breaking UTC-to-local conversion for all report date displays
- Make open/partially_paid table chips more visually distinct on dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:18:46 +03:00
parent 5da378a0ae
commit 72b12ddd9c
7 changed files with 84 additions and 37 deletions

View File

@@ -19,7 +19,7 @@ class Settings(BaseSettings):
CLOUD_URL: str = "" CLOUD_URL: str = ""
SECRET_KEY: str = "change-me-generate-a-long-random-string" SECRET_KEY: str = "change-me-generate-a-long-random-string"
DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}" DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}"
VERSION: str = "0.0.0" VERSION: str = "0.3.1"
CONNECT_SYNC_INTERVAL_SECONDS: int = 30 # how often to poll for pending online orders CONNECT_SYNC_INTERVAL_SECONDS: int = 30 # how often to poll for pending online orders
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"} model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}

View File

@@ -1,6 +1,16 @@
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from tz import local_strftime
def _iso(dt) -> str | None:
"""Serialize a datetime to ISO 8601 with Z suffix so browsers parse it as UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.isoformat()
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
@@ -163,11 +173,11 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"removed_ingredients": i.removed_ingredients, "removed_ingredients": i.removed_ingredients,
"notes": i.notes, "notes": i.notes,
"status": i.status, "status": i.status,
"added_at": i.added_at, "added_at": _iso(i.added_at),
"printed": i.printed, "printed": i.printed,
"paid_by": i.paid_by, "paid_by": i.paid_by,
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None, "paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
"paid_at": i.paid_at, "paid_at": _iso(i.paid_at),
"payment_method": i.payment_method, "payment_method": i.payment_method,
"paid_in_shift_id": i.paid_in_shift_id, "paid_in_shift_id": i.paid_in_shift_id,
} }
@@ -183,8 +193,8 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"amount": l.amount, "amount": l.amount,
"payment_method": l.payment_method, "payment_method": l.payment_method,
"note": l.note, "note": l.note,
"created_at": l.created_at, "created_at": _iso(l.created_at),
"offline_at": l.offline_at, "offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at,
"is_duplicate": l.is_duplicate, "is_duplicate": l.is_duplicate,
} }
@@ -205,9 +215,9 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"table_id": order.table_id, "table_id": order.table_id,
"table_name": table_name, "table_name": table_name,
"opened_by": order.opened_by, "opened_by": order.opened_by,
"opened_at": order.opened_at, "opened_at": _iso(order.opened_at),
"status": order.status, "status": order.status,
"closed_at": order.closed_at, "closed_at": _iso(order.closed_at),
"closed_by": order.closed_by, "closed_by": order.closed_by,
"notes": order.notes, "notes": order.notes,
"business_day_id": order.business_day_id, "business_day_id": order.business_day_id,
@@ -657,8 +667,8 @@ def print_order(
"order_id": order.id, "order_id": order.id,
"table_name": table_name, "table_name": table_name,
"waiter_name": waiter_name, "waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), "opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None, "closed_at": local_strftime(order.closed_at, "%d/%m/%Y %H:%M") if order.closed_at else None,
"status": order.status, "status": order.status,
"items": items_data, "items": items_data,
"total": grand_total, "total": grand_total,
@@ -953,7 +963,7 @@ def print_synopsis(
"order_id": order.id, "order_id": order.id,
"table_name": table_name, "table_name": table_name,
"waiter_name": waiter_name, "waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), "opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"items": items_data, "items": items_data,
"total": total, "total": total,
"paid_total": paid_total, "paid_total": paid_total,

View File

@@ -3,6 +3,7 @@ import io
import json import json
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from tz import local_strftime, to_local
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -87,8 +88,8 @@ def shift_summary(
order = db.query(Order).filter(Order.id == oid).first() order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = { summary[wid]["order_data"][oid] = {
"id": oid, "id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "", "time_open": local_strftime(order.opened_at, "%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "", "time_close": local_strftime(order.closed_at, "%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0, "total": 0.0,
"items": [], "items": [],
@@ -160,8 +161,8 @@ def shift_orders_summary(
order = db.query(Order).filter(Order.id == oid).first() order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = { summary[wid]["order_data"][oid] = {
"id": oid, "id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "", "time_open": local_strftime(order.opened_at, "%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "", "time_close": local_strftime(order.closed_at, "%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0, "total": 0.0,
"items": [], "items": [],
@@ -382,7 +383,7 @@ def printer_totals(
order = db.query(Order).filter(Order.id == oid).first() order = db.query(Order).filter(Order.id == oid).first()
order_map[pid][oid] = { order_map[pid][oid] = {
"order_id": oid, "order_id": oid,
"time": log.printed_at.strftime("%H:%M"), "time": local_strftime(log.printed_at, "%H:%M"),
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0, "total": 0.0,
"items": [], "items": [],
@@ -452,7 +453,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
if order: if order:
order_map[oid] = { order_map[oid] = {
"id": oid, "id": oid,
"time": log.printed_at.strftime("%H:%M"), "time": local_strftime(log.printed_at, "%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"), "table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0, "total": 0.0,
"items": [], "items": [],
@@ -535,8 +536,8 @@ def print_waiter(
total = sum(i.unit_price * i.quantity for i in active_items) total = sum(i.unit_price * i.quantity for i in active_items)
order_data.append({ order_data.append({
"id": o.id, "id": o.id,
"time_open": o.opened_at.strftime("%H:%M"), "time_open": local_strftime(o.opened_at, "%H:%M"),
"time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "", "time_close": local_strftime(o.closed_at, "%H:%M") if o.closed_at else "",
"table": tables.get(o.table_id, f"#{o.table_id}"), "table": tables.get(o.table_id, f"#{o.table_id}"),
"total": total, "total": total,
"items": [ "items": [
@@ -556,8 +557,8 @@ def print_waiter(
"items": items_count, "items": items_count,
"total": grand_total, "total": grand_total,
"order_data": order_data if body.mode == "extensive" else [], "order_data": order_data if body.mode == "extensive" else [],
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"), "from_dt": local_strftime(from_dt, "%d/%m/%Y %H:%M"),
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"), "to_dt": local_strftime(to_dt, "%d/%m/%Y %H:%M"),
} }
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode) background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
@@ -592,13 +593,13 @@ def print_printer_totals(
# Determine period label for the receipt header # Determine period label for the receipt header
if body.business_day_id: if body.business_day_id:
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first() bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}" period_label = local_strftime(bd.opened_at, "%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
period_is_workday = True period_is_workday = True
else: else:
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
period_label = ( period_label = (
f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}" f"{local_strftime(from_dt, '%d/%m/%Y')} - {local_strftime(to_dt, '%d/%m/%Y')}"
if from_dt and to_dt else "" if from_dt and to_dt else ""
) )
period_is_workday = False period_is_workday = False
@@ -649,12 +650,12 @@ def _analytics_period(body: PrintAnalyticsBody, db) -> tuple[str, bool]:
"""Return (period_label, period_is_workday).""" """Return (period_label, period_is_workday)."""
if body.business_day_id: if body.business_day_id:
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first() bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}" label = local_strftime(bd.opened_at, "%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
return label, True return label, True
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
label = ( label = (
f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}" f"{local_strftime(from_dt, '%d/%m/%Y')} - {local_strftime(to_dt, '%d/%m/%Y')}"
if from_dt and to_dt else "" if from_dt and to_dt else ""
) )
return label, False return label, False
@@ -1253,7 +1254,7 @@ def revenue_trends(
buckets: dict = {} buckets: dict = {}
for order in orders: for order in orders:
dt = order.opened_at dt = to_local(order.opened_at)
if granularity == "monthly": if granularity == "monthly":
key = dt.strftime("%Y-%m") key = dt.strftime("%Y-%m")
elif granularity == "weekly": elif granularity == "weekly":

View File

@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from database import SessionLocal from database import SessionLocal
from models.order import Order, OrderItem, PrintLog from models.order import Order, OrderItem, PrintLog
from tz import to_local
from models.printer import Printer from models.printer import Printer
from models.product import Product from models.product import Product
from models.settings import PosSettings from models.settings import PosSettings
@@ -232,8 +233,8 @@ def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
def _greek_date(dt: datetime.datetime) -> str: def _greek_date(dt: datetime.datetime) -> str:
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY""" """Return date/time string in Greek format: HH:MM DD-MM-YYYY, converted to venue local time."""
return dt.strftime("%H:%M %d-%m-%Y") return to_local(dt).strftime("%H:%M %d-%m-%Y")
def check_printer(ip: str, port: int) -> bool: def check_printer(ip: str, port: int) -> bool:
@@ -282,7 +283,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
p._raw(b'\x1b\x21\x30') p._raw(b'\x1b\x21\x30')
_raw_text(p, f"TEST — {name}\n") _raw_text(p, f"TEST — {name}\n")
p._raw(b'\x1b\x21\x00') p._raw(b'\x1b\x21\x00')
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = to_local(datetime.datetime.now(datetime.timezone.utc)).strftime("%Y-%m-%d %H:%M:%S")
_raw_text(p, f"{now}\n") _raw_text(p, f"{now}\n")
p._raw(b'\n\n\n') p._raw(b'\n\n\n')
p.cut() p.cut()
@@ -499,7 +500,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
# Resolve display names # Resolve display names
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id) table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by) waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
now_str = _greek_date(datetime.datetime.now()) now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc))
# ── COMPACT header — single line ──────────────────────────────────────── # ── COMPACT header — single line ────────────────────────────────────────
if compact: if compact:

39
local_backend/tz.py Normal file
View File

@@ -0,0 +1,39 @@
"""Timezone utilities for converting UTC datetimes to the venue's local time."""
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from datetime import datetime, timezone
from database import SessionLocal
from models.settings import PosSettings
_FALLBACK = "Europe/Athens"
def _get_tz_name() -> str:
db = SessionLocal()
try:
row = db.query(PosSettings).filter(PosSettings.key == "system.timezone").first()
return row.value if row and row.value else _FALLBACK
except Exception:
return _FALLBACK
finally:
db.close()
def to_local(dt: datetime) -> datetime:
"""Convert a UTC-aware datetime to the venue's configured local timezone."""
if dt is None:
return dt
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
tz = ZoneInfo(_get_tz_name())
except ZoneInfoNotFoundError:
tz = ZoneInfo(_FALLBACK)
return dt.astimezone(tz)
def local_strftime(dt: datetime, fmt: str) -> str:
"""Format a UTC-aware datetime in local time using the venue's timezone."""
if dt is None:
return ""
return to_local(dt).strftime(fmt)

View File

@@ -65,8 +65,8 @@ function Avatar({ name, avatarUrl, size = 36 }) {
// ─── Table color tokens ──────────────────────────────────────────────────────── // ─── Table color tokens ────────────────────────────────────────────────────────
const TABLE_COLORS = { const TABLE_COLORS = {
open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' }, open: { bg: '#bbf7d0', border: '#4ade80', dot: '#16a34a', text: '#14532d', label: 'Ανοιχτό' },
partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' }, partially_paid: { bg: '#e9d5ff', border: '#a855f7', dot: '#7c3aed', text: '#4c1d95', label: 'Μερική πληρ.' },
free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' }, free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
} }

View File

@@ -94,13 +94,9 @@ export const fmtTime = (iso) => {
} }
export const fmtDate = (iso) => { export const fmtDate = (iso) => {
if (!iso) return '—' if (!iso) return '—'
// Parse as local time by replacing the T separator so Date treats it as local const d = new Date(iso)
const d = new Date(String(iso).replace('T', ' '))
if (isNaN(d)) return String(iso) if (isNaN(d)) return String(iso)
const day = String(d.getDate()).padStart(2, '0') return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
} }
// Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion // Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion