diff --git a/backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py b/backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py new file mode 100644 index 0000000..72c3cf6 --- /dev/null +++ b/backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py @@ -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') diff --git a/backend/app/api/v1/endpoints/holidays.py b/backend/app/api/v1/endpoints/holidays.py new file mode 100644 index 0000000..cab700c --- /dev/null +++ b/backend/app/api/v1/endpoints/holidays.py @@ -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() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 30b9a19..682304b 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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.app_version import router as app_version_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() @@ -29,3 +30,4 @@ api_router.include_router(records_router) api_router.include_router(notifications_router) api_router.include_router(app_version_router) api_router.include_router(analytics_router) +api_router.include_router(holidays_router) diff --git a/backend/app/core/time_utils.py b/backend/app/core/time_utils.py index 18ec6bd..865dc30 100644 --- a/backend/app/core/time_utils.py +++ b/backend/app/core/time_utils.py @@ -1,5 +1,5 @@ -"""全局北京时间 (UTC+8)""" -from datetime import datetime +"""全局北京时间 (UTC+8) 与工作日时长计算""" +from datetime import datetime, date, time, timedelta, timezone from zoneinfo import ZoneInfo BEIJING_TZ = ZoneInfo("Asia/Shanghai") @@ -8,3 +8,48 @@ BEIJING_TZ = ZoneInfo("Asia/Shanghai") def get_beijing_time() -> datetime: """返回当前北京时间""" 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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 87dcd1f..70e033a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.task_log import TaskLog from app.models.notification import Notification from app.models.app_version import AppVersion from app.models.message import ProductMessage +from app.models.holiday import Holiday __all__ = [ "Base", "ProductionOrder", @@ -17,4 +18,5 @@ __all__ = [ "Notification", "AppVersion", "ProductMessage", + "Holiday", ] diff --git a/backend/app/models/holiday.py b/backend/app/models/holiday.py new file mode 100644 index 0000000..53e3c30 --- /dev/null +++ b/backend/app/models/holiday.py @@ -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""