fix(时长): 所有时长计算排除非工作日 + 修复两处时区多算8小时

- get_wip_tasks/get_people_workload/get_people_history/product active_duration_hours/analytics 均改用 working_duration_hours
- 修复 get_wip_tasks 与 active_duration_hours 的 naive 时间误标北京时间 bug(DB实存UTC,原多算8小时)
- 每处读取 holidays 表排除配置的放假日期
This commit is contained in:
2026-08-28 15:08:10 +08:00
parent ad4b55dece
commit bad0941d67
3 changed files with 40 additions and 22 deletions

View File

@ -114,11 +114,12 @@ def _to_bj(dt: datetime | None) -> datetime | None:
return dt
def _duration_hours(start: datetime | None, end: datetime) -> float:
"""计算单台耗时(小时),无开始时间返回 0。"""
def _duration_hours(start: datetime | None, end: datetime, holidays: set = None) -> float:
"""计算单台耗时(工作小时,排除周末/节假日),无开始时间返回 0。"""
if not start:
return 0.0
return (end - start).total_seconds() / 3600
from app.core.time_utils import working_duration_hours
return working_duration_hours(start, end, holidays)
# ============================================================
@ -142,10 +143,15 @@ async def get_capability_profile(
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
)
from app.models.product import Product
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time
now = get_beijing_time()
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = (
select(
Task.assignee_id, Task.status,
@ -196,7 +202,7 @@ async def get_capability_profile(
"hours": 0.0, "prio": 9,
"first_start": None, "last_completed": None,
})
entry["hours"] += _duration_hours(start, end)
entry["hours"] += _duration_hours(start, end, holidays)
if start and (entry["first_start"] is None or start < entry["first_start"]):
entry["first_start"] = start
completed_dt = _to_bj(row[4]) if row[4] else None

View File

@ -209,7 +209,12 @@ async def get_dashboard_stats(
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
@ -231,12 +236,9 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
for task, product_sn, ext_sn, mat_name, spec in rows:
start = task.received_at or task.created_at
if start:
if start.tzinfo is None:
start = start.replace(tzinfo=BEIJING_TZ)
else:
start = start.astimezone(BEIJING_TZ)
hours = round((now - start).total_seconds() / 3600, 1)
recv_str = start.strftime("%m-%d %H:%M")
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间修复多算8小时
hours = working_duration_hours(start_bj, now, holidays)
recv_str = start_bj.strftime("%m-%d %H:%M")
else:
hours = 0
recv_str = ""
@ -683,7 +685,12 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
"""上帝视角 — 按负责人聚合当前在制品设备WIP/PENDING 任务product 去重,含滞留时长)。"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = (
select(
@ -749,7 +756,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
devices: list[PersonDevice] = []
for product_id, e in prods.items():
dt = e["earliest_dt"]
hours = round((now - dt).total_seconds() / 3600, 1) if dt else 0.0
hours = working_duration_hours(dt, now, holidays) if dt else 0.0
received_str = dt.strftime("%m-%d %H:%M") if dt else None
devices.append(PersonDevice(
product_id=product_id,
@ -787,11 +794,16 @@ async def get_people_history(
"""上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED"""
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.core.time_utils import get_beijing_time, BEIJING_TZ, to_beijing, working_duration_hours
from app.models.holiday import Holiday
from sqlalchemy import func
now = get_beijing_time()
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
# ── 关联 TaskRecord最新有效备注 + 记录总数 ──
from app.models.task import TaskRecord
from sqlalchemy import and_, or_
@ -915,7 +927,7 @@ async def get_people_history(
else:
end_dt = now
completed_str = None
hours = round((end_dt - received_dt).total_seconds() / 3600, 1) if received_dt else 0.0
hours = working_duration_hours(received_dt, end_dt, holidays) if received_dt else 0.0
records.append(PersonHistoryRecord(
task_id=str(row[0]),
task_name=row[1] or "",

View File

@ -541,11 +541,14 @@ async def get_all_products(
has_img = bool(row[3] and row[3] != "[]" and row[3] != "null")
latest_record_map[row[0]] = (row[1], row[2], has_img, row[4])
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时(排除非工作日)
active_duration_map: dict[uuid.UUID, float] = {}
if product_ids:
from sqlalchemy import func as sa_func
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
from app.models.holiday import Holiday
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
start_stmt = (
select(
Task.product_id,
@ -563,11 +566,8 @@ async def get_all_products(
start = row[1]
if start is None:
continue
if start.tzinfo is None:
start = start.replace(tzinfo=BEIJING_TZ)
else:
start = start.astimezone(BEIJING_TZ)
active_duration_map[row[0]] = round((now - start).total_seconds() / 3600, 1)
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间修复多算8小时
active_duration_map[row[0]] = working_duration_hours(start_bj, now, holidays)
return [
ProductResponse(