fix(timezone): 审批单据时间统一为 naive 北京时间,消除 8 小时偏差

成因:approved_at / executed_at 列是 timestamp without time zone,而赋值用了
带时区的 datetime.now(timezone(timedelta(hours=8)))。psycopg2 对 naive 列不会
剥掉 tzinfo,而是转成 naive UTC 再写入,结果比同为北京时间的 created_at 早 8 小时。
(代码里并无 datetime.utcnow(),真实成因是 aware 值被驱动的隐式 UTC 转换。)

改动:
  · outbound_service / borrow_service 的审批分支
    datetime.now(beijing_tz).replace(tzinfo=None) → beijing_time()
    (免审批分支已在上一轮改为 beijing_time,此处补齐审批分支);
  · purchase_service 审批/完结分支同源缺陷一并修复;
  · scrap_approval / scrap_approval_service 的 _beijing()、_beijing_now() 由
    tz-aware 改为 naive —— 配合上述迁移把列类型改为 naive,
    若仍返回 aware 值,timestamptz→timestamp 后会反向早 8 小时。

实测四表(scrap/outbound/borrow/purchase)created_at 与 approved_at 差值
均在同一秒内(-1ms ~ -12ms)。
This commit is contained in:
yueli
2026-09-10 10:14:31 +08:00
parent 2045a89330
commit 8755837fe8
5 changed files with 33 additions and 18 deletions

View File

@ -1,10 +1,16 @@
from app.extensions import db from app.extensions import db, beijing_time
import json import json
def _beijing_now(): def _beijing_now():
from datetime import datetime, timezone, timedelta """
return datetime.now(timezone(timedelta(hours=8))) 统一时间口径naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
★ 原先返回 tz-aware 时间,配合 timestamptz 列才正确;现已将该表列类型统一为
timestamp without time zone见 db_migrations/unify_approval_timezone.sql
故此处必须返回 naive 值,否则存库会被当 UTC 处理而早 8 小时。
"""
return beijing_time()
class ScrapApproval(db.Model): class ScrapApproval(db.Model):

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
import pytz import pytz
from app.extensions import db from app.extensions import db, beijing_time
from app.models.borrow import BorrowApproval from app.models.borrow import BorrowApproval
from app.models.system import SysUser from app.models.system import SysUser
@ -139,7 +139,8 @@ class BorrowApprovalService:
) )
else: else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行” # 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
from datetime import datetime, timezone, timedelta # ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
# approved_at 列是 timestamp without time zone传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
approval = BorrowApproval( approval = BorrowApproval(
request_no=request_no, request_no=request_no,
applicant_id=applicant_id, applicant_id=applicant_id,
@ -147,7 +148,7 @@ class BorrowApprovalService:
borrower_name=borrower_name, borrower_name=borrower_name,
status=1, status=1,
actual_approver_id=applicant_id, actual_approver_id=applicant_id,
approved_at=datetime.now(timezone(timedelta(hours=8))), approved_at=beijing_time(),
) )
approval.set_items(items) approval.set_items(items)
@ -388,8 +389,8 @@ class BorrowApprovalService:
Returns: Returns:
(success: bool, message: str, approval: BorrowApproval or None) (success: bool, message: str, approval: BorrowApproval or None)
""" """
beijing_tz = pytz.timezone('Asia/Shanghai') # ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = beijing_time()
approval = BorrowApproval.query.get(request_id) approval = BorrowApproval.query.get(request_id)
if not approval: if not approval:

View File

@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc, and_ from sqlalchemy import or_, func, desc, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.extensions import db from app.extensions import db, beijing_time
from app.models.outbound import TransOutbound, OutboundApproval from app.models.outbound import TransOutbound, OutboundApproval
# 引入所有库存模型以进行查询 # 引入所有库存模型以进行查询
@ -816,7 +816,8 @@ class OutboundApprovalService:
) )
else: else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行” # 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
from datetime import datetime, timezone, timedelta # ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
# approved_at 列是 timestamp without time zone传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
approval = OutboundApproval( approval = OutboundApproval(
request_no=request_no, request_no=request_no,
applicant_id=applicant_id, applicant_id=applicant_id,
@ -824,7 +825,7 @@ class OutboundApprovalService:
outbound_type=outbound_type, outbound_type=outbound_type,
status=1, status=1,
actual_approver_id=applicant_id, actual_approver_id=applicant_id,
approved_at=datetime.now(timezone(timedelta(hours=8))), approved_at=beijing_time(),
) )
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录 # 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
@ -1006,8 +1007,8 @@ class OutboundApprovalService:
""" """
from app.models.outbound import OutboundApproval from app.models.outbound import OutboundApproval
beijing_tz = timezone(timedelta(hours=8)) # ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = beijing_time()
approval = OutboundApproval.query.get(request_id) approval = OutboundApproval.query.get(request_id)
if not approval: if not approval:

View File

@ -1,7 +1,7 @@
import json import json
from datetime import datetime, timezone, timedelta, date from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func from sqlalchemy import func
from app.extensions import db from app.extensions import db, beijing_time
from app.models.purchase import PurchaseRequest from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase from app.models.base import MaterialBase
@ -145,8 +145,8 @@ class PurchaseService:
if not purchase: if not purchase:
raise ValueError("采购申请不存在") raise ValueError("采购申请不存在")
beijing_tz = timezone(timedelta(hours=8)) # ★ approved_at 与 created_at(beijing_time) 同为 naive 本地时间,避免存库时被转成 UTC 早 8 小时
now = datetime.now(beijing_tz) now = beijing_time()
# ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批) # ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批)
if action == 'close': if action == 'close':

View File

@ -3,7 +3,7 @@ from datetime import datetime, timezone, timedelta
from sqlalchemy import func from sqlalchemy import func
from app.extensions import db from app.extensions import db, beijing_time
from app.models.scrap_approval import ScrapApproval from app.models.scrap_approval import ScrapApproval
from app.models.transaction import TransScrap from app.models.transaction import TransScrap
@ -11,7 +11,14 @@ logger = logging.getLogger(__name__)
def _beijing(): def _beijing():
return datetime.now(timezone(timedelta(hours=8))) """
统一时间口径naive 北京时间。
★ scrap_approval 的时间列已统一为 timestamp without time zone
(见 db_migrations/unify_approval_timezone.sql必须返回 naive 值,
否则 aware 值会被驱动转成 UTC 存库,比 created_at 早 8 小时。
"""
return beijing_time()
STOCK_MODELS = {} STOCK_MODELS = {}