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

@@ -1,6 +1,16 @@
import json
from datetime import datetime, timezone
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 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,
"notes": i.notes,
"status": i.status,
"added_at": i.added_at,
"added_at": _iso(i.added_at),
"printed": i.printed,
"paid_by": i.paid_by,
"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,
"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,
"payment_method": l.payment_method,
"note": l.note,
"created_at": l.created_at,
"offline_at": l.offline_at,
"created_at": _iso(l.created_at),
"offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at,
"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_name": table_name,
"opened_by": order.opened_by,
"opened_at": order.opened_at,
"opened_at": _iso(order.opened_at),
"status": order.status,
"closed_at": order.closed_at,
"closed_at": _iso(order.closed_at),
"closed_by": order.closed_by,
"notes": order.notes,
"business_day_id": order.business_day_id,
@@ -657,8 +667,8 @@ def print_order(
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
"opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"closed_at": local_strftime(order.closed_at, "%d/%m/%Y %H:%M") if order.closed_at else None,
"status": order.status,
"items": items_data,
"total": grand_total,
@@ -953,7 +963,7 @@ def print_synopsis(
"order_id": order.id,
"table_name": table_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,
"total": total,
"paid_total": paid_total,

View File

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