feat(工作日): 新增工作小时计算工具 + 节假日表与管理接口
- time_utils 新增 to_beijing(统一naive按UTC转北京时间) + working_duration_hours(排除周末/节假日) - Holiday 模型 + alembic 迁移建 holidays 表 - GET/POST/DELETE /holidays 节假日管理接口,可随时配置放假日期
This commit is contained in:
32
backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py
Normal file
32
backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""add holidays table
|
||||||
|
|
||||||
|
Revision ID: h1h2h3h4h5h6
|
||||||
|
Revises: g1h2i3j4k5l6
|
||||||
|
Create Date: 2026-08-28 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'h1h2h3h4h5h6'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'g1h2i3j4k5l6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'holidays',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, primary_key=True),
|
||||||
|
sa.Column('day', sa.Date(), nullable=False, comment='放假日期'),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=True, comment='放假说明(如国庆节)'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_holidays_day', 'holidays', ['day'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_holidays_day', table_name='holidays')
|
||||||
|
op.drop_table('holidays')
|
||||||
60
backend/app/api/v1/endpoints/holidays.py
Normal file
60
backend/app/api/v1/endpoints/holidays.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""节假日管理 — 工作日时长计算所需的放假日期配置"""
|
||||||
|
from datetime import date
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.holiday import Holiday
|
||||||
|
from app.services.auth_service import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/holidays", tags=["节假日管理"])
|
||||||
|
|
||||||
|
|
||||||
|
class HolidayCreate(BaseModel):
|
||||||
|
day: date = Field(..., description="放假日期")
|
||||||
|
name: str | None = Field(None, max_length=100, description="放假说明")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_holidays(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取全部放假日期(按日期正序)"""
|
||||||
|
result = await db.execute(select(Holiday).order_by(Holiday.day.asc()))
|
||||||
|
return [
|
||||||
|
{"id": h.id, "date": h.day.isoformat(), "name": h.name}
|
||||||
|
for h in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", status_code=201)
|
||||||
|
async def create_holiday(
|
||||||
|
data: HolidayCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""添加一个放假日期"""
|
||||||
|
exists = await db.scalar(select(Holiday).where(Holiday.day == data.day))
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{data.day} 已在节假日列表中")
|
||||||
|
h = Holiday(day=data.day, name=data.name)
|
||||||
|
db.add(h)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(h)
|
||||||
|
return {"id": h.id, "date": h.day.isoformat(), "name": h.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{holiday_id}", status_code=204)
|
||||||
|
async def delete_holiday(
|
||||||
|
holiday_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""删除一个放假日期"""
|
||||||
|
h = await db.get(Holiday, holiday_id)
|
||||||
|
if not h:
|
||||||
|
raise HTTPException(status_code=404, detail="节假日不存在")
|
||||||
|
await db.delete(h)
|
||||||
|
await db.commit()
|
||||||
@ -13,6 +13,7 @@ from app.api.v1.endpoints.records import router as records_router
|
|||||||
from app.api.v1.endpoints.notifications import router as notifications_router
|
from app.api.v1.endpoints.notifications import router as notifications_router
|
||||||
from app.api.v1.endpoints.app_version import router as app_version_router
|
from app.api.v1.endpoints.app_version import router as app_version_router
|
||||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||||
|
from app.api.v1.endpoints.holidays import router as holidays_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@ -29,3 +30,4 @@ api_router.include_router(records_router)
|
|||||||
api_router.include_router(notifications_router)
|
api_router.include_router(notifications_router)
|
||||||
api_router.include_router(app_version_router)
|
api_router.include_router(app_version_router)
|
||||||
api_router.include_router(analytics_router)
|
api_router.include_router(analytics_router)
|
||||||
|
api_router.include_router(holidays_router)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""全局北京时间 (UTC+8)"""
|
"""全局北京时间 (UTC+8) 与工作日时长计算"""
|
||||||
from datetime import datetime
|
from datetime import datetime, date, time, timedelta, timezone
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
@ -8,3 +8,48 @@ BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
|||||||
def get_beijing_time() -> datetime:
|
def get_beijing_time() -> datetime:
|
||||||
"""返回当前北京时间"""
|
"""返回当前北京时间"""
|
||||||
return datetime.now(BEIJING_TZ)
|
return datetime.now(BEIJING_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def to_beijing(dt: datetime | None) -> datetime | None:
|
||||||
|
"""将任意 datetime 统一转为北京时间 aware。
|
||||||
|
|
||||||
|
- naive 时间按 UTC 处理(数据库 timestamptz 实存 UTC,SQLAlchemy 读出常为 naive)
|
||||||
|
- 带时区时间直接 astimezone 到北京
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
|
||||||
|
return dt.astimezone(BEIJING_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def working_duration_hours(
|
||||||
|
start: datetime | None,
|
||||||
|
end: datetime | None,
|
||||||
|
holidays: set[date] | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""计算 start~end 之间排除周末与节假日的工作小时数。
|
||||||
|
|
||||||
|
- 周末(周六/周日)整天排除
|
||||||
|
- holidays 中配置的放假日期整天排除
|
||||||
|
- 其余日期按 24 小时连续计(一天内的时间都算)
|
||||||
|
- start/end 可为 naive(按 UTC 转)或 aware 北京时间
|
||||||
|
"""
|
||||||
|
if not holidays:
|
||||||
|
holidays = set()
|
||||||
|
s = to_beijing(start)
|
||||||
|
e = to_beijing(end)
|
||||||
|
if s is None or e is None or e <= s:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
total = 0.0
|
||||||
|
day = s.date()
|
||||||
|
last = e.date()
|
||||||
|
while day <= last:
|
||||||
|
if day.weekday() < 5 and day not in holidays:
|
||||||
|
seg_start = max(s, datetime.combine(day, time.min, tzinfo=BEIJING_TZ))
|
||||||
|
seg_end = min(e, datetime.combine(day, time.max, tzinfo=BEIJING_TZ))
|
||||||
|
if seg_end > seg_start:
|
||||||
|
total += (seg_end - seg_start).total_seconds() / 3600
|
||||||
|
day += timedelta(days=1)
|
||||||
|
return round(total, 1)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from app.models.task_log import TaskLog
|
|||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.app_version import AppVersion
|
from app.models.app_version import AppVersion
|
||||||
from app.models.message import ProductMessage
|
from app.models.message import ProductMessage
|
||||||
|
from app.models.holiday import Holiday
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ProductionOrder",
|
"ProductionOrder",
|
||||||
@ -17,4 +18,5 @@ __all__ = [
|
|||||||
"Notification",
|
"Notification",
|
||||||
"AppVersion",
|
"AppVersion",
|
||||||
"ProductMessage",
|
"ProductMessage",
|
||||||
|
"Holiday",
|
||||||
]
|
]
|
||||||
|
|||||||
21
backend/app/models/holiday.py
Normal file
21
backend/app/models/holiday.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""节假日模型 — 放假日期配置,用于工作日时长计算"""
|
||||||
|
from datetime import date
|
||||||
|
from sqlalchemy import Date, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Holiday(Base):
|
||||||
|
__tablename__ = "holidays"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
day: Mapped[date] = mapped_column(
|
||||||
|
Date, unique=True, index=True, comment="放假日期",
|
||||||
|
)
|
||||||
|
name: Mapped[str | None] = mapped_column(
|
||||||
|
String(100), nullable=True, comment="放假说明(如国庆节)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Holiday {self.day}>"
|
||||||
Reference in New Issue
Block a user